epiphany 3 сар өмнө
parent
commit
481d0a0fc3

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 675 - 4
bin/app/Cargo.lock


+ 3 - 1
bin/app/Cargo.toml

@@ -20,9 +20,11 @@ glam = "0.30.9"
 #async_zmq = "0.4.0"
 zeromq = { version = "0.4.1", default-features = false, features = ["async-std-runtime", "all-transport"] }
 darkfi = {path = "../../", features = ["async-daemonize", "event-graph", "net", "util", "system", "zk"]}
-#darkfi-sdk = {path = "../../src/sdk", features = ["async"]}
+darkfi-sdk = {path = "../../src/sdk", features = ["async"]}
 darkfi-serial = {path = "../../src/serial", features = ["async"]}
+darkfi_money_contract = { path = "../../src/contract/money/" }
 fud = { path = "../fud/fud/" }
+drk = { path = "../drk/" }
 bs58 = "0.5.1"
 thiserror = "2.0.17"
 smol = "2.0.2"

+ 55 - 0
bin/app/src/app/node.rs

@@ -745,3 +745,58 @@ pub fn create_menu(name: &str) -> SceneNode {
 
     node
 }
+
+pub fn create_tokentable(name: &str) -> SceneNode {
+    let mut node = SceneNode::new(name, SceneNodeType::TokenTable);
+
+    let mut prop = Property::new("rect", PropertyType::Float32, PropertySubType::Pixel);
+    prop.set_array_len(4);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("font_size", PropertyType::Float32, PropertySubType::Pixel);
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("text_color", PropertyType::Float32, PropertySubType::Color);
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![1., 1., 1., 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("separator_color", PropertyType::Float32, PropertySubType::Color);
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0.3, 0.3, 0.3, 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("column_spacing", PropertyType::Float32, PropertySubType::Pixel);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("padding_x", PropertyType::Float32, PropertySubType::Pixel);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("padding_y", PropertyType::Float32, PropertySubType::Pixel);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("z_index", PropertyType::Uint32, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("priority", PropertyType::Uint32, PropertySubType::Null);
+    node.add_property(prop).unwrap();
+
+    node.add_signal(
+        "row_click",
+        "Row clicked event",
+        vec![("row", "Token row data", CallArgType::Hash)],
+    )
+    .unwrap();
+
+    node.add_method(
+        "set_tokens",
+        vec![("tokens", "Token rows data", CallArgType::Hash)],
+        None,
+    )
+    .unwrap();
+
+    node
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 656 - 139
bin/app/src/app/schema/wallet.rs


+ 255 - 5
bin/app/src/main.rs

@@ -22,6 +22,9 @@
 
 use clap::Parser;
 use darkfi::system::CondVar;
+use darkfi::tx::Transaction;
+use darkfi_money_contract::model::DARK_TOKEN_ID;
+use darkfi_serial::{Decodable, Encodable, deserialize};
 use std::sync::{Arc, OnceLock};
 
 #[macro_use]
@@ -54,18 +57,17 @@ use crate::{
     app::{App, AppPtr},
     gfx::EpochIndex,
     prop::{Property, PropertySubType, PropertyType},
-    scene::{CallArgType, SceneNode, SceneNodeType},
+    scene::{CallArgType, SceneNode, SceneNodePtr, SceneNodeType},
     util::AsyncRuntime,
 };
 #[cfg(feature = "enable-netdebug")]
 use net::ZeroMQAdapter;
 #[cfg(feature = "enable-plugins")]
 use {
-    darkfi_serial::{deserialize, Decodable, Encodable},
     // Local imports
     gfx::Renderer,
     prop::{PropertyBool, PropertyStr, Role},
-    scene::{SceneNodePtr, Slot},
+    scene::Slot,
     std::io::Cursor,
     ui::chatview,
     // Global imports
@@ -345,6 +347,7 @@ async fn load_plugins(
     let (slot, recvr) = Slot::new("connect");
     darkirc.register("connect", slot).unwrap();
     let sg_root2 = sg_root.clone();
+    let renderer2 = renderer.clone();
     let listen_connect = ex.spawn(async move {
         let net0 = sg_root2.lookup_node("/window/content/netstatus_layer/net0").unwrap();
         let net1 = sg_root2.lookup_node("/window/content/netstatus_layer/net1").unwrap();
@@ -359,7 +362,7 @@ async fn load_plugins(
         while let Ok(data) = recvr.recv().await {
             let (peers_count, is_dag_synced): (u32, bool) = deserialize(&data).unwrap();
 
-            let atom = &mut renderer.make_guard(gfxtag!("netstatus change"));
+            let atom = &mut renderer2.make_guard(gfxtag!("netstatus change"));
 
             if peers_count == 0 {
                 net0_is_visible.set(atom, true);
@@ -426,8 +429,185 @@ async fn load_plugins(
 
     plugin.link(fud);
 
+    let drk = create_drk("drk");
+    let sg_root2 = sg_root.clone();
+    let ex2 = ex.clone();
+    let (drk_pimpl_send, drk_pimpl_recv) = smol::channel::bounded(1);
+    let drk = drk
+        .setup(move |me| async move {
+            // Drk uses rusqlite which is not Send, so we run it on a dedicated thread
+            let handle = std::thread::spawn(move || {
+                let (pimpl, local_ex) = smol::block_on(plugin::DrkPlugin::new(me, sg_root2, ex2))
+                    .expect("Drk pimpl setup");
+
+                // Send Pimpl back to the setup closure
+                smol::block_on(drk_pimpl_send.send(pimpl)).unwrap();
+
+                // Block on local executor to process spawned tasks forever
+                smol::block_on(local_ex.run(async { futures::future::pending::<()>().await }));
+            });
+
+            // Wait for Pimpl to be created
+            let pimpl = drk_pimpl_recv.recv().await.unwrap();
+
+            drop(handle);
+
+            pimpl
+        })
+        .await;
+
+    let (slot, recv) = Slot::new("balances_update");
+    let _ = drk.register("balances_updated", slot);
+    let sg_root2 = sg_root.clone();
+    let renderer2 = renderer.clone();
+    let drk_node2 = drk.clone();
+    let listen_balances = ex.spawn(async move {
+        use crate::ui::TokenRow;
+        use darkfi_money_contract::model::TokenId;
+        use darkfi_serial::Encodable;
+
+        while let Ok(_) = recv.recv().await {
+            d!("drk balances_updated signal received");
+
+            // Fetch and update main wallet tokens table
+            if let Ok(Some(response_data)) = drk_node2.call_method("get_balances", vec![]).await {
+                let atom = &mut renderer2.make_guard(gfxtag!("wallet - refresh tokens"));
+
+                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);
+                    }
+
+                    if let Some(tokens_table) = sg_root2.lookup_node("/window/content/wallet_main_layer/tokens_table") {
+                        let _ = tokens_table.call_method("set_tokens", data.clone()).await;
+                    }
+
+                    if let Some(send_tokens_table) = sg_root2.lookup_node("/window/content/wallet_send_step1_layer/tokens_table") {
+                        let _ = send_tokens_table.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();
+                        }
+                    }
+
+                    if let Some(tx_status_layer) = sg_root2.lookup_node("/window/content/wallet_tx_status_layer") {
+                        let tx_id = tx_status_layer.get_property_str("tx_id").unwrap();
+                        if !tx_id.is_empty() {
+                            let mut tx_id_data = vec![];
+                            tx_id.encode(&mut tx_id_data).unwrap();
+                            if let Ok(Some(data)) = drk_node2.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();
+                                if let Some(status_node) = tx_status_layer.lookup_node("/status") {
+                                    status_node.set_property_str(atom, Role::App, "text", status_text).unwrap();
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    });
+
+    let (slot, recv) = Slot::new("tx_updated");
+    let _ = drk.register("tx_updated", slot);
+    let sg_root2 = sg_root.clone();
+    let listen_tx = ex.spawn(async move {
+        while let Ok(data) = recv.recv().await {
+            if let Some(tx_status_layer) = sg_root2.lookup_node("/window/content/wallet_tx_status_layer") {
+                let _ = tx_status_layer.call_method("set_tx_status", data).await;
+            }
+        }
+    });
+
+    // Listen for tx_built signal - emitted when transaction is built (non-blocking)
+    let (slot, recv) = Slot::new("tx_built");
+    let _ = drk.register("tx_built", slot);
+    let sg_root2 = sg_root.clone();
+    let renderer2 = renderer.clone();
+    let listen_tx_built = ex.spawn(async move {
+        while let Ok(data) = recv.recv().await {
+            let mut cur = std::io::Cursor::new(data);
+            let amount = String::decode(&mut cur).unwrap();
+            let token_symbol = String::decode(&mut cur).unwrap();
+            let recipient_str = String::decode(&mut cur).unwrap();
+
+            // Decode transaction and pass to wallet schema
+            let tx = Transaction::decode(&mut cur).unwrap();
+
+            // Update tx_status_layer with built transaction
+            let atom = &mut renderer2.make_guard(gfxtag!("tx built"));
+            if let Some(tx_status) = sg_root2.lookup_node("/window/content/wallet_tx_status_layer") {
+                let mut tx_status_data = vec![];
+                None::<String>.encode(&mut tx_status_data).unwrap();
+                Some("Broadcasting transaction...".to_string()).encode(&mut tx_status_data).unwrap();
+                Some(amount).encode(&mut tx_status_data).unwrap();
+                Some(token_symbol).encode(&mut tx_status_data).unwrap();
+                Some(recipient_str).encode(&mut tx_status_data).unwrap();
+                let _ = tx_status.call_method("set_tx_status", tx_status_data).await;
+
+                // Call set_built_tx to store transaction for later broadcast
+                let mut set_built_tx_data = vec![];
+                tx.encode(&mut set_built_tx_data).unwrap();
+                let _ = tx_status.call_method("set_built_tx", set_built_tx_data).await;
+            }
+
+            // Hide step3 layer
+            if let Some(step4_layer) = sg_root2.lookup_node("/window/content/wallet_send_step3_layer") {
+                step4_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+            }
+
+            // Show step4 layer
+            if let Some(step4_layer) = sg_root2.lookup_node("/window/content/wallet_send_step4_layer") {
+                step4_layer.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
+            }
+        }
+    });
+
+    // Listen for tx_built_error signal - emitted when transaction building fails
+    let (slot, recv) = Slot::new("tx_built_error");
+    let _ = drk.register("tx_built_error", slot);
+    let sg_root2 = sg_root.clone();
+    let renderer2 = renderer.clone();
+    let listen_tx_built_error = ex.spawn(async move {
+        while let Ok(data) = recv.recv().await {
+            let mut cur = std::io::Cursor::new(data);
+            let error_message = String::decode(&mut cur).unwrap();
+            let atom = &mut renderer2.make_guard(gfxtag!("tx built error"));
+
+            // TODO: display error somewhere
+
+            // Reset button state
+            if let Some(btn_node) = sg_root2.lookup_node("/window/content/wallet_send_step3_layer/send_amount_button") {
+                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_amount_button_label") {
+                    label_node.set_property_str(atom, Role::App, "text", "add amount").unwrap();
+                }
+            }
+        }
+    });
+
+    plugin.link(drk);
+
     i!("Plugins loaded");
-    futures::join!(listen_recv, listen_connect, listen_file_status);
+    futures::join!(listen_recv, listen_connect, listen_file_status, listen_balances, listen_tx, listen_tx_built, listen_tx_built_error);
 }
 
 pub fn create_darkirc(name: &str) -> SceneNode {
@@ -495,6 +675,76 @@ pub fn create_fud(name: &str) -> SceneNode {
     node
 }
 
+pub fn create_drk(name: &str) -> SceneNode {
+    t!("create_drk({name})");
+    let mut node = SceneNode::new(name, SceneNodeType::Plugin);
+
+    node.add_method(
+        "get_default_address",
+        vec![],
+        Some(vec![("address", "Default address", CallArgType::Str)]),
+    ).unwrap();
+
+    node.add_method(
+        "get_balances",
+        vec![],
+        Some(vec![("balances", "Token balances", CallArgType::Hash)]),
+    ).unwrap();
+
+    node.add_method(
+        "get_tx_status",
+        vec![("tx_id", "Transaction hash", CallArgType::Str)],
+        Some(vec![("status_text", "Status text", CallArgType::Str)]),
+    ).unwrap();
+
+    node.add_method(
+        "build_tx",
+        vec![
+            ("amount", "Amount", CallArgType::Str),
+            ("token_id", "Token ID", CallArgType::Hash),
+            ("recipient", "Recipient address", CallArgType::Str),
+        ],
+        Some(vec![("tx", "Transaction", CallArgType::Hash)]),
+    ).unwrap();
+
+    node.add_method(
+        "broadcast_tx",
+        vec![("tx", "Transaction", CallArgType::Hash)],
+        Some(vec![("status_text", "Status text", CallArgType::Str)]),
+    ).unwrap();
+
+    node.add_signal("balances_updated", "Balances changed", vec![]).unwrap();
+
+    node.add_signal(
+        "tx_updated",
+        "Transaction status updated",
+        vec![
+            ("tx_id", "Transaction ID", CallArgType::Str),
+            ("status_text", "Transaction status text", CallArgType::Str),
+        ],
+    ).unwrap();
+
+    node.add_signal(
+        "tx_built",
+        "Transaction built - for wallet send flow",
+        vec![
+            ("amount", "Amount", CallArgType::Str),
+            ("token_symbol", "Token symbol", CallArgType::Str),
+            ("recipient_str", "Recipient address", CallArgType::Str),
+        ],
+    ).unwrap();
+
+    node.add_signal(
+        "tx_built_error",
+        "Transaction build error",
+        vec![
+            ("error_message", "Error message", CallArgType::Str),
+        ],
+    ).unwrap();
+
+    node
+}
+
 /// Simple program to greet a person
 #[derive(Parser, Debug)]
 #[command(version, about, long_about = None)]

+ 681 - 0
bin/app/src/plugin/drk.rs

@@ -0,0 +1,681 @@
+/* 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::{
+    net::{
+        P2p, P2pPtr, session::{SESSION_DIRECT, SESSION_INBOUND}, settings::{MagicBytes, NetworkProfile, Settings as NetSettings}
+    },
+    system::{Publisher, PublisherPtr, StoppableTask, sleep},
+    tx::Transaction,
+    util::parse::encode_base10,
+};
+use darkfi_money_contract::model::TokenId;
+use darkfi_serial::{Decodable, Encodable};
+use darkfi_sdk::crypto::{FuncId, keypair::{Address, Network, PublicKey, StandardAddress}};
+use drk::{Drk, money::BALANCE_BASE10_DECIMALS, rpc::subscribe_blocks};
+use sled_overlay::sled;
+use smol::lock::{Mutex, RwLock};
+use smol::channel::unbounded;
+use std::{
+    collections::HashSet,
+    io::Cursor,
+    path::PathBuf,
+    str::FromStr,
+    sync::{Arc, OnceLock, Weak},
+};
+use url::Url;
+
+use crate::{
+    error::{Error, Result},
+    prop::{BatchGuardPtr, PropertyAtomicGuard, PropertyBool, Role},
+    scene::{
+        MethodCall, MethodCallSub, Pimpl, SceneNode, SceneNodePtr, SceneNodeType, SceneNodeWeak,
+    },
+    ui::{chatview::FileMessageStatus, OnModify},
+    ExecutorPtr,
+};
+
+use super::PluginSettings;
+
+const DARKFID_RETRY_TIME: u64 = 20;
+
+#[cfg(target_os = "android")]
+mod paths {
+    use crate::android::{get_appdata_path, get_external_storage_path};
+    use std::path::PathBuf;
+
+    pub fn get_cache_path() -> PathBuf {
+        get_external_storage_path().join("drk/cache")
+    }
+    pub fn get_wallet_path() -> PathBuf {
+        get_external_storage_path().join("drk/wallet.db")
+    }
+    pub fn get_use_tor_filename() -> PathBuf {
+        get_external_storage_path().join("use_tor.txt")
+    }
+}
+
+#[cfg(not(target_os = "android"))]
+mod paths {
+    use std::path::PathBuf;
+
+    pub fn get_cache_path() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/drk/cache")
+    }
+    pub fn get_wallet_path() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/drk/wallet.db")
+    }
+    pub fn get_use_tor_filename() -> PathBuf {
+        dirs::data_local_dir().unwrap().join("darkfi/app/drk/use_tor.txt")
+    }
+}
+
+use paths::*;
+
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "plugin::drk", $($arg)*); } }
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "plugin::drk", $($arg)*); } }
+macro_rules! i { ($($arg:tt)*) => { info!(target: "plugin::drk", $($arg)*); } }
+macro_rules! e { ($($arg:tt)*) => { error!(target: "plugin::drk", $($arg)*); } }
+
+#[derive(Debug, Clone)]
+enum TxStatus {
+    Building,
+    Broadcasting,
+    Confirming,
+    Confirmed,
+    Error(String),
+}
+
+impl TxStatus {
+    fn text(&self) -> String {
+        match self {
+            TxStatus::Building => "Building transaction...".to_string(),
+            TxStatus::Broadcasting => "Broadcasting transaction...".to_string(),
+            TxStatus::Confirming => "Confirming transaction...".to_string(),
+            TxStatus::Confirmed => "Transaction confirmed".to_string(),
+            TxStatus::Error(ref err) => format!("Error sending transaction: {err}"),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+struct TxState {
+    id: Option<String>,
+    status: TxStatus,
+    amount: String,
+    token_symbol: String,
+    recipient: Address,
+}
+
+pub type DrkPluginPtr = Arc<DrkPlugin>;
+
+#[derive(Debug, Clone)]
+struct BuildTxRequest {
+    amount: String,
+    token_id: TokenId,
+    recipient: PublicKey,
+}
+
+pub struct DrkPlugin {
+    node: SceneNodeWeak,
+    sg_root: SceneNodePtr,
+    tasks: OnceLock<Vec<smol::Task<()>>>,
+
+    drk: Arc<RwLock<Drk>>,
+    build_tx_channel: smol::channel::Sender<BuildTxRequest>,
+}
+
+impl DrkPlugin {
+    pub async fn new<'a>(
+        node: SceneNodeWeak,
+        sg_root: SceneNodePtr,
+        ex: ExecutorPtr,
+    ) -> Result<(Pimpl, Arc<smol::LocalExecutor<'a>>)> {
+        let node_ref = &node.upgrade().unwrap();
+
+        let setting_root = Arc::new(SceneNode::new("setting", SceneNodeType::SettingRoot));
+        node_ref.clone().link(setting_root.clone());
+
+        let endpoint = Url::parse("tcp://127.0.0.1:28345").unwrap();
+
+        let drk = match Drk::new(Network::Mainnet, get_cache_path().to_string_lossy().to_string(), get_wallet_path().to_string_lossy().to_string(), "changeme".to_string(), Some(endpoint), &ex, false).await {
+            Ok(wallet) => wallet,
+            Err(e) => {
+                eprintln!("Error initializing wallet: {e}");
+                return Err(Error::ServiceFailed); // TODO: make a better error
+            }
+        };
+
+        if let Err(e) = drk.initialize_wallet().await {
+            e!("Error initializing wallet: {e}");
+        }
+        let mut output = vec![];
+        if let Err(e) = drk.initialize_money(&mut output).await {
+            e!("Failed to initialize Money: {e}");
+        }
+
+        if let Err(e) = drk.initialize_dao().await {
+            e!("Failed to initialize DAO: {e}");
+        }
+        if let Err(e) = drk.initialize_deployooor() {
+            e!("Failed to initialize Deployooor: {e}");
+        }
+
+        // Generate a default address if needed
+        match drk.default_address().await {
+            Ok(_) => {
+                i!("Default address already exists");
+            }
+            Err(e) => {
+                i!("No default address found ({}), generating one...", e);
+                if let Err(e) = drk.money_keygen(&mut output).await {
+                    e!("Failed to generate keypair: {e}");
+                } else {
+                    i!("Generated default address");
+                    match drk.addresses().await {
+                        Ok(addrs) => {
+                            if let Some((key_id, _, _, _)) = addrs.last() {
+                                i!("Setting address with key_id {} as default", key_id);
+                                if let Err(e) = drk.set_default_address(*key_id as usize) {
+                                    e!("Failed to set default address: {e}");
+                                }
+                            }
+                        }
+                        Err(e) => {
+                            e!("Failed to get addresses: {e}");
+                        }
+                    }
+                }
+            }
+        }
+
+        // Create channel for build_tx requests
+        let (build_tx_tx, build_tx_rx) = smol::channel::unbounded();
+
+        let self_ = Arc::new(Self {
+            node: node.clone(),
+            sg_root,
+            tasks: OnceLock::new(),
+            drk: drk.into_ptr(),
+            build_tx_channel: build_tx_tx,
+        });
+
+        let local_ex = smol::LocalExecutor::new();
+        let local_ex = Arc::new(local_ex);
+
+        // Start background task to process build_tx requests from channel
+        let me3 = Arc::downgrade(&self_);
+        let build_tx_processor = local_ex.spawn(async move {
+            while let Ok(request) = build_tx_rx.recv().await {
+                if let Some(self_) = me3.upgrade() {
+                    match self_.build_tx_request(request).await {
+                        Ok((tx, token_symbol, recipient, amount)) => {
+                            self_.emit_tx_built(amount, token_symbol, recipient, tx).await;
+                        }
+                        Err(e) => {
+                            e!("Failed to build transaction: {e}");
+                            self_.emit_tx_built_error(e.to_string()).await;
+                        }
+                    }
+                }
+            }
+        });
+
+        let node_ref = node.upgrade().unwrap();
+        let me2 = Arc::downgrade(&self_);
+        let method_sub = node_ref.subscribe_method_call("get_default_address").unwrap();
+        let get_address_task = local_ex.spawn(async move {
+            while Self::process_get_default_address(&me2, &method_sub).await {}
+        });
+
+        let node_ref = node.upgrade().unwrap();
+        let me2 = Arc::downgrade(&self_);
+        let method_sub_balances = node_ref.subscribe_method_call("get_balances").unwrap();
+        let get_balances_task = local_ex.spawn(async move {
+            while Self::process_get_balances(&me2, &method_sub_balances).await {}
+        });
+
+        let node_ref = node.upgrade().unwrap();
+        let me2 = Arc::downgrade(&self_);
+        let method_sub_tx_status = node_ref.subscribe_method_call("get_tx_status").unwrap();
+        let get_tx_status_task = local_ex.spawn(async move {
+            while Self::process_get_tx_status(&me2, &method_sub_tx_status).await {}
+        });
+
+        let node_ref = node.upgrade().unwrap();
+        let me2 = Arc::downgrade(&self_);
+        let method_sub_build_tx = node_ref.subscribe_method_call("build_tx").unwrap();
+        let build_tx_task = local_ex.spawn(async move {
+            while Self::process_build_tx(&me2, &method_sub_build_tx).await {}
+        });
+
+        let node_ref = node.upgrade().unwrap();
+        let me2 = Arc::downgrade(&self_);
+        let method_sub_broadcast_tx = node_ref.subscribe_method_call("broadcast_tx").unwrap();
+        let broadcast_tx_task = local_ex.spawn(async move {
+            while Self::process_broadcast_tx(&me2, &method_sub_broadcast_tx).await {}
+        });
+
+        self_.clone().start(ex.clone(), local_ex.clone(), get_address_task, get_balances_task, get_tx_status_task, build_tx_task, broadcast_tx_task, build_tx_processor).await;
+
+        Ok((Pimpl::Drk(self_), local_ex))
+    }
+
+    async fn apply_settings(self_: Arc<Self>, _batch: BatchGuardPtr) {
+        // TODO
+    }
+
+    pub async fn get_default_address(&self) -> Result<String> {
+        let drk = self.drk.read().await;
+        let pubkey = drk.default_address().await.map_err(|e| {
+            e!("Failed to get default address: {e}");
+            Error::ServiceFailed
+        })?;
+
+        let network = drk.network;
+        let address: darkfi_sdk::crypto::keypair::Address =
+            StandardAddress::from_public(network, pubkey).into();
+
+        Ok(address.to_string())
+    }
+
+    pub async fn get_balances(&self) -> Result<Vec<(String, TokenId, f32)>> {
+        let drk = self.drk.read().await;
+
+        let balances = drk.money_balance().await.map_err(|e| {
+            e!("Failed to get money balance: {e}");
+            Error::ServiceFailed
+        })?;
+
+        let aliases = drk.get_aliases_mapped_by_token().await.map_err(|e| {
+            e!("Failed to get aliases: {e}");
+            Error::ServiceFailed
+        })?;
+
+        let mut result: Vec<(String, TokenId, f32)> = Vec::new();
+        for (token_id_str, balance) in balances {
+            let encoded = encode_base10(balance, BALANCE_BASE10_DECIMALS);
+            let float_balance: f32 = encoded.parse().unwrap_or(0.0);
+            let alias = aliases.get(&token_id_str).cloned().unwrap_or_else(|| "UNKN".to_string());
+            let token_id = token_id_str.parse::<TokenId>().unwrap();
+
+            result.push((alias, token_id, float_balance));
+        }
+
+        // Sort by balance
+        result.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
+
+        Ok(result)
+    }
+
+    /// Emit balances_updated signal
+    pub async fn emit_balances_updated(&self) {
+        if let Some(node) = self.node.upgrade() {
+            let _ = node.trigger("balances_updated", vec![]).await;
+        }
+    }
+
+    /// Emit tx_updated signal
+    pub async fn emit_tx_updated(&self, state: &TxState) {
+        if let Some(node) = self.node.upgrade() {
+            let mut data = vec![];
+            state.id.clone().encode(&mut data).unwrap();
+            Some(state.status.text()).encode(&mut data).unwrap();
+            Some(state.amount.to_string()).encode(&mut data).unwrap();
+            Some(state.token_symbol.clone()).encode(&mut data).unwrap();
+            Some(state.recipient.to_string()).encode(&mut data).unwrap();
+            let _ = node.trigger("tx_updated", data).await;
+        }
+    }
+    pub async fn emit_tx_status_updated(&self, status: &TxStatus) {
+        if let Some(node) = self.node.upgrade() {
+            let mut data = vec![];
+            None::<String>.encode(&mut data).unwrap();
+            Some(status.text()).encode(&mut data).unwrap();
+            None::<String>.encode(&mut data).unwrap();
+            None::<String>.encode(&mut data).unwrap();
+            None::<String>.encode(&mut data).unwrap();
+            let _ = node.trigger("tx_updated", data).await;
+        }
+    }
+
+    /// Emit tx_built signal when transaction is built
+    pub async fn emit_tx_built(&self, amount: String, token_symbol: String, recipient: Address, tx: Transaction) {
+        if let Some(node) = self.node.upgrade() {
+            let mut data = vec![];
+            amount.encode(&mut data).unwrap();
+            token_symbol.encode(&mut data).unwrap();
+            recipient.to_string().encode(&mut data).unwrap();
+            tx.encode(&mut data).unwrap();
+            let _ = node.trigger("tx_built", data).await;
+        }
+    }
+
+    /// Emit tx_built_error signal when transaction building fails
+    pub async fn emit_tx_built_error(&self, error: String) {
+        if let Some(node) = self.node.upgrade() {
+            let mut data = vec![];
+            error.encode(&mut data).unwrap();
+            let _ = node.trigger("tx_built_error", data).await;
+        }
+    }
+
+    async fn process_get_default_address(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("get_default_address method closed");
+            return false
+        };
+
+        t!("method called: get_default_address()");
+
+        let Some(self_) = me.upgrade() else {
+            e!("drk plugin destroyed before get_default_address task was stopped!");
+            if let Some(send_res) = method_call.send_res {
+                let _ = send_res.send(vec![]).await;
+            }
+            return false
+        };
+
+        let address = match self_.get_default_address().await {
+            Ok(addr) => addr,
+            Err(e) => {
+                e!("Failed to get default address: {e}");
+                if let Some(send_res) = method_call.send_res {
+                    let _ = send_res.send(vec![]).await;
+                }
+                return true
+            }
+        };
+
+        i!("Got default address: {address}");
+
+        if let Some(send_res) = method_call.send_res {
+            let mut cur = Cursor::new(vec![]);
+            if address.encode(&mut cur).is_ok() {
+                let _ = send_res.send(cur.into_inner()).await;
+            } else {
+                e!("Failed to encode default address");
+                let _ = send_res.send(vec![]).await;
+            }
+        } else {
+            e!("No send_res channel available");
+        }
+
+        true
+    }
+
+    async fn process_get_balances(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("get_balances method closed");
+            return false
+        };
+
+        t!("method called: get_balances()");
+
+        let Some(self_) = me.upgrade() else {
+            e!("drk plugin destroyed before get_balances task was stopped!");
+            if let Some(send_res) = method_call.send_res {
+                let _ = send_res.send(vec![]).await;
+            }
+            return false
+        };
+
+        let balances = match self_.get_balances().await {
+            Ok(b) => b,
+            Err(e) => {
+                e!("Failed to get balances: {e}");
+                if let Some(send_res) = method_call.send_res {
+                    let _ = send_res.send(vec![]).await;
+                }
+                return true
+            }
+        };
+
+        if let Some(send_res) = method_call.send_res {
+            let mut cur = Cursor::new(vec![]);
+            if balances.encode(&mut cur).is_ok() {
+                let _ = send_res.send(cur.into_inner()).await;
+            } else {
+                e!("Failed to encode balances");
+                let _ = send_res.send(vec![]).await;
+            }
+        } else {
+            e!("No send_res channel available");
+        }
+
+        true
+    }
+
+    async fn process_get_tx_status(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("get_tx_history method closed");
+            return false
+        };
+
+        t!("method called: get_tx_history()");
+
+        fn decode_data(data: &[u8]) -> std::io::Result<String> {
+            let mut cur = Cursor::new(&data);
+            let tx_id = String::decode(&mut cur)?;
+            Ok(tx_id)
+        }
+
+        let Ok(tx_id) = decode_data(&method_call.data) else {
+            d!("get_tx_history() method invalid arg data");
+            return true
+        };
+
+        let Some(self_) = me.upgrade() else {
+            e!("drk plugin destroyed before get_tx_history task was stopped!");
+            if let Some(send_res) = method_call.send_res {
+                let _ = send_res.send(vec![]).await;
+            }
+            return false
+        };
+
+        let drk = self_.drk.read().await;
+        let Ok((_, status, block_height, tx)) = drk.get_tx_history_record(&tx_id).await else {
+            d!("get_tx_history() method failed to get tx history record");
+            return true
+        };
+        if let Some(send_res) = method_call.send_res {
+            let mut cur = Cursor::new(vec![]);
+            let status = match status.as_str() {
+                "Broadcasted" => TxStatus::Confirming,
+                "Confirmed" => TxStatus::Confirmed,
+                _ => TxStatus::Error("unknown status".to_string()),
+            };
+            if status.text().encode(&mut cur).is_ok() {
+                let _ = send_res.send(cur.into_inner()).await;
+            } else {
+                e!("Failed to encode balances");
+                let _ = send_res.send(vec![]).await;
+            }
+        } else {
+            e!("No send_res channel available");
+        }
+
+        true
+    }
+
+    /// Build a transaction without broadcasting it
+    pub async fn build_tx(&self, amount: &str, token_id: TokenId, recipient: PublicKey) -> Result<Transaction> {
+        let drk = self.drk.read().await;
+
+        drk.transfer(amount, token_id, recipient, None, None, false).await.map_err(|e| {
+            e!("Failed to build transaction: {e}");
+            Error::ServiceFailed
+        })
+    }
+
+    /// Build a transaction from a BuildTxRequest (called by background task)
+    async fn build_tx_request(&self, request: BuildTxRequest) -> Result<(Transaction, String, Address, String)> {
+        let drk = self.drk.read().await;
+        let aliases = drk.get_aliases_mapped_by_token().await.unwrap_or_default();
+        let token_symbol = aliases.get(&request.token_id.to_string()).unwrap_or(&"UNKN".to_string()).to_string();
+        let recipient: Address = StandardAddress::from_public(drk.network, request.recipient).into();
+
+        let tx = self.build_tx(&request.amount, request.token_id, request.recipient).await?;
+
+        Ok((tx, token_symbol, recipient, request.amount))
+    }
+
+    async fn process_build_tx(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("build_tx method closed");
+            return false
+        };
+
+        t!("method called: build_tx()");
+
+        // Send empty response immediately to unblock the caller
+        if let Some(send_res) = method_call.send_res {
+            let _ = send_res.send(vec![]).await;
+        }
+
+        fn decode_data(data: &[u8]) -> std::io::Result<(String, TokenId, PublicKey)> {
+            let mut cur = Cursor::new(&data);
+            let amount = String::decode(&mut cur)?;
+            let token_id = TokenId::decode(&mut cur)?;
+            let recipient = PublicKey::decode(&mut cur)?;
+            Ok((amount, token_id, recipient))
+        }
+
+        let Ok((amount, token_id, recipient)) = decode_data(&method_call.data) else {
+            d!("build_tx() method invalid arg data");
+            return true
+        };
+
+        let Some(self_) = me.upgrade() else {
+            e!("drk plugin destroyed before build_tx task was stopped!");
+            return false
+        };
+
+        // Send request to channel for background processing
+        let request = BuildTxRequest { amount, token_id, recipient };
+        let _ = self_.build_tx_channel.send(request).await;
+
+        true
+    }
+
+    /// Broadcast a transaction
+    pub async fn broadcast_tx(&self, tx: Transaction) -> Result<String> {
+        let drk = self.drk.read().await;
+        let tx_id = drk.broadcast_tx(&tx, &mut vec![]).await.map_err(|e| {
+            e!("Failed to broadcast transaction: {e}");
+            Error::ServiceFailed
+        })?;
+
+        Ok(tx_id)
+    }
+
+    async fn process_broadcast_tx(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("broadcast_tx method closed");
+            return false
+        };
+
+        t!("method called: broadcast_tx()");
+
+        let Some(send_res) = method_call.send_res else {
+            return true
+        };
+
+        // Send empty response immediately to unblock the caller
+        let _ = send_res.send(vec![]).await;
+
+        let Some(self_) = me.upgrade() else {
+            e!("drk plugin destroyed before broadcast_tx task was stopped!");
+            return false
+        };
+
+        let Ok(tx) = Transaction::decode(&mut Cursor::new(&method_call.data)) else {
+            d!("broadcast_tx() method invalid arg data");
+            return true
+        };
+
+        let result = self_.broadcast_tx(tx).await;
+
+        match result {
+            Ok(tx_id) => {
+                self_.emit_tx_status_updated(&TxStatus::Confirming).await;
+            }
+            Err(_) => {
+                e!("Failed to broadcast transaction");
+                self_.emit_tx_status_updated(&TxStatus::Error("failed to broadcast".to_string())).await;
+                return true
+            }
+        }
+
+        true
+    }
+
+    async fn start(self: Arc<Self>, ex: ExecutorPtr, local_ex: Arc<smol::LocalExecutor<'_>>, get_address_task: smol::Task<()>, get_balances_task: smol::Task<()>, get_tx_status_task: smol::Task<()>, build_tx_task: smol::Task<()>, broadcast_tx_task: smol::Task<()>, build_tx_processor: smol::Task<()>) {
+        let endpoint = Url::parse("tcp://127.0.0.1:28345").unwrap();
+
+        let me = Arc::downgrade(&self);
+        let node = &self.node.upgrade().unwrap();
+
+        let drk = self.drk.clone();
+        let (shell_sender, shell_receiver) = unbounded();
+        let ex_ = ex.clone();
+
+        // Task that handles the RPC subscription with retry logic
+        let subscribe_task = local_ex.spawn(async move {
+            loop {
+                i!("Attempting to connect to darkfid daemon at {}", endpoint);
+                let subscribe_rpc_task = StoppableTask::new();
+                let shell_sender = shell_sender.clone();
+                let drk = drk.clone();
+                let endpoint = endpoint.clone();
+                let ex = ex_.clone();
+
+                match subscribe_blocks(&drk, subscribe_rpc_task, shell_sender.clone(), endpoint, &ex).await {
+                    Ok(()) => {
+                        i!("darkfid subscription closed normally (detached task stopped)");
+                    }
+                    Err(e) => {
+                        e!("darkfid connection failed: {e}");
+                    }
+                }
+
+                // Wait before retrying
+                i!("Retrying connection to darkfid in {} seconds...", DARKFID_RETRY_TIME);
+                sleep(DARKFID_RETRY_TIME).await;
+            }
+        });
+
+        let self2 = self.clone();
+        let subscribe_recv_task = local_ex.spawn(async move {
+            loop {
+                let recv = shell_receiver.recv().await;
+
+                if let Ok(lines) = recv {
+                    self2.emit_balances_updated().await;
+
+                    for line in lines.iter() {
+                        i!(line);
+                    }
+                }
+            }
+        });
+
+        let tasks = vec![subscribe_task, subscribe_recv_task, get_address_task, get_balances_task, get_tx_status_task, build_tx_task, broadcast_tx_task, build_tx_processor];
+        self.tasks.set(tasks).unwrap();
+    }
+}

+ 4 - 0
bin/app/src/plugin/mod.rs

@@ -25,8 +25,12 @@ pub use darkirc::DarkIrcPtr;
 pub mod fud;
 pub use fud::FudPluginPtr as FudPtr;
 
+pub mod drk;
+pub use drk::DrkPluginPtr as DrkPtr;
+
 pub use darkirc::DarkIrc;
 pub use fud::FudPlugin;
+pub use drk::DrkPlugin;
 
 use darkfi::net::Settings as NetSettings;
 

+ 4 - 0
bin/app/src/scene.rs

@@ -120,6 +120,7 @@ pub enum SceneNodeType {
     SettingRoot = 20,
     Setting = 21,
     Menu = 22,
+    TokenTable = 23,
     PluginRoot = 100,
     Plugin = 101,
 }
@@ -590,10 +591,13 @@ pub enum Pimpl {
     Gesture(ui::GesturePtr),
     EmojiPicker(ui::EmojiPickerPtr),
     Menu(ui::MenuPtr),
+    TokenTable(ui::TokenTablePtr),
     #[cfg(feature = "enable-plugins")]
     DarkIrc(plugin::DarkIrcPtr),
     #[cfg(feature = "enable-plugins")]
     Fud(plugin::FudPtr),
+    #[cfg(feature = "enable-plugins")]
+    Drk(plugin::DrkPtr),
 }
 
 impl std::fmt::Debug for Pimpl {

+ 4 - 0
bin/app/src/ui/mod.rs

@@ -33,6 +33,8 @@ mod button;
 pub use button::{Button, ButtonPtr};
 pub mod chatview;
 pub use chatview::{ChatView, ChatViewPtr};
+pub mod tokentable;
+pub use tokentable::{TokenRow, TokenTable, TokenTablePtr};
 mod edit;
 pub use edit::{BaseEdit, BaseEditPtr, BaseEditType};
 pub mod emoji_picker;
@@ -226,6 +228,7 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::Shortcut(obj) => obj.clone(),
         Pimpl::Gesture(obj) => obj.clone(),
         Pimpl::Menu(obj) => obj.clone(),
+        Pimpl::TokenTable(obj) => obj.clone(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }
@@ -244,6 +247,7 @@ pub fn get_ui_object3<'a>(node: &'a SceneNode3) -> &'a dyn UIObject {
         Pimpl::Shortcut(obj) => obj.as_ref(),
         Pimpl::Gesture(obj) => obj.as_ref(),
         Pimpl::Menu(obj) => obj.as_ref(),
+        Pimpl::TokenTable(obj) => obj.as_ref(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }

+ 366 - 0
bin/app/src/ui/tokentable/mod.rs

@@ -0,0 +1,366 @@
+/* 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 async_trait::async_trait;
+use darkfi_money_contract::model::TokenId;
+use darkfi_serial::{Decodable, Encodable, SerialEncodable};
+use miniquad::MouseButton;
+use parking_lot::Mutex as SyncMutex;
+use rand::{rngs::OsRng, Rng};
+use std::sync::{Arc, Weak};
+
+use crate::{
+    gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer},
+    mesh::MeshBuilder,
+    prop::{
+        BatchGuardId, PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyRect,
+        PropertyUint32, Role,
+    },
+    scene::{SceneNodePtr, SceneNodeWeak},
+    text,
+    ui::Pimpl,
+    ExecutorPtr,
+};
+
+use super::{DrawUpdate, UIObject};
+
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::tokentable", $($arg)*); } }
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::tokentable", $($arg)*); } }
+
+#[derive(Clone, Debug, SerialEncodable)]
+pub struct TokenRow {
+    pub id: TokenId,
+    pub symbol: String,
+    pub balance: String,
+}
+
+impl Decodable for TokenRow {
+    fn decode<R: std::io::Read>(r: &mut R) -> Result<Self, std::io::Error> {
+        let id = TokenId::decode(r)?;
+        let symbol = String::decode(r)?;
+        let balance = String::decode(r)?;
+        Ok(Self { id, symbol, balance })
+    }
+}
+
+pub type TokenTablePtr = Arc<TokenTable>;
+
+pub struct TokenTable {
+    node: SceneNodeWeak,
+    renderer: Renderer,
+    sg_root: SceneNodePtr,
+
+    rows: SyncMutex<Vec<TokenRow>>,
+    dc_key: u64,
+
+    rect: PropertyRect,
+    z_index: PropertyUint32,
+    priority: PropertyUint32,
+
+    font_size: PropertyFloat32,
+    text_color: PropertyColor,
+    separator_color: PropertyColor,
+    column_spacing: PropertyFloat32,
+    padding_x: PropertyFloat32,
+    padding_y: PropertyFloat32,
+
+    parent_rect: SyncMutex<Option<Rectangle>>,
+    tasks: SyncMutex<Vec<smol::Task<()>>>,
+}
+
+impl TokenTable {
+    pub async fn new(
+        node: SceneNodeWeak,
+        renderer: Renderer,
+        sg_root: SceneNodePtr,
+    ) -> Pimpl {
+        let node_ref = &node.upgrade().unwrap();
+        let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap();
+        let font_size = PropertyFloat32::wrap(node_ref, Role::Internal, "font_size", 0).unwrap();
+        let text_color = PropertyColor::wrap(node_ref, Role::Internal, "text_color").unwrap();
+        let separator_color = PropertyColor::wrap(node_ref, Role::Internal, "separator_color").unwrap();
+        let column_spacing = PropertyFloat32::wrap(node_ref, Role::Internal, "column_spacing", 0).unwrap();
+        let padding_x = PropertyFloat32::wrap(node_ref, Role::Internal, "padding_x", 0).unwrap();
+        let padding_y = PropertyFloat32::wrap(node_ref, Role::Internal, "padding_y", 0).unwrap();
+        let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap();
+        let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
+
+        let self_ = Arc::new(Self {
+            node: node.clone(),
+            renderer: renderer.clone(),
+            sg_root,
+            rows: SyncMutex::new(vec![]),
+            dc_key: OsRng.gen(),
+            rect,
+            z_index,
+            priority,
+            font_size,
+            text_color,
+            separator_color,
+            column_spacing,
+            padding_x,
+            padding_y,
+            parent_rect: SyncMutex::new(None),
+            tasks: SyncMutex::new(vec![]),
+        });
+        Pimpl::TokenTable(self_)
+    }
+
+    async fn process_set_tokens_method(me: &Weak<Self>, data: Vec<u8>) -> bool {
+        fn decode_data(data: &[u8]) -> std::io::Result<Vec<TokenRow>> {
+            let mut cur = std::io::Cursor::new(data);
+            let mut rows = vec![];
+            while cur.position() < data.len() as u64 {
+                let row = TokenRow::decode(&mut cur)?;
+                rows.push(row);
+            }
+            Ok(rows)
+        }
+
+        let Ok(rows) = decode_data(&data) else {
+            error!(target: "ui::tokentable", "set_tokens() method invalid arg data");
+            return true
+        };
+
+        let Some(self_) = me.upgrade() else {
+            error!(target: "ui::tokentable", "self destroyed before set_tokens method task was stopped!");
+            return false
+        };
+
+        self_.set_tokens(rows).await;
+        true
+    }
+
+    pub async fn set_tokens(&self, rows: Vec<TokenRow>) {
+        *self.rows.lock() = rows;
+
+        let atom = self.renderer.make_guard(gfxtag!("TokenTable::set_tokens"));
+        self.redraw_cached(atom.batch_id).await;
+    }
+
+    /// Get row at specific screen y position
+    fn get_row_at_y(&self, mouse_y: f32) -> Option<TokenRow> {
+        let rect = self.rect.get();
+        let padding_y = self.padding_y.get();
+        let font_size = self.font_size.get();
+        let row_height = padding_y * 2. + font_size + 1.;
+
+        let y = mouse_y - rect.y;
+
+        let rows = self.rows.lock();
+        if y < 0. || y > rows.len() as f32 * row_height {
+            return None
+        }
+
+        let row_index = (y / row_height).floor() as usize;
+        if row_index < rows.len() {
+            Some(rows[row_index].clone())
+        } else {
+            None
+        }
+    }
+
+    /// Invalidates cache and redraws everything
+    async fn redraw_all(&self, atom: &mut PropertyAtomicGuard) {
+        let parent_rect = self.parent_rect.lock().unwrap().clone();
+        self.rect.eval(atom, &parent_rect).expect("unable to eval rect");
+        self.redraw_cached(atom.batch_id).await;
+    }
+
+    async fn redraw_cached(&self, batch_id: BatchGuardId) {
+        let rect = self.rect.get();
+
+        let mut mesh_instrs = self.get_meshes(&rect).await;
+
+        let mut instrs = vec![DrawInstruction::ApplyView(rect)];
+        instrs.append(&mut mesh_instrs);
+
+        let draw_calls =
+            vec![(self.dc_key, DrawCall::new(instrs, vec![], self.z_index.get(), "tokentable"))];
+
+        self.renderer.replace_draw_calls(Some(batch_id), draw_calls);
+    }
+
+    async fn get_meshes(&self, rect: &Rectangle) -> Vec<DrawInstruction> {
+        let rows = self.rows.lock();
+        let font_size = self.font_size.get();
+        let text_color = self.text_color.get();
+        let separator_color = self.separator_color.get();
+        let padding_x = self.padding_x.get();
+        let padding_y = self.padding_y.get();
+        let column_spacing = self.column_spacing.get();
+
+        let mut instrs = vec![];
+
+        for (i, row) in rows.iter().enumerate() {
+            let row_height = padding_y*2.+font_size+1.;
+            let y_pos = (i as f32) * row_height;
+
+            // Render symbol
+            let symbol_layout = text::make_layout(
+                &row.symbol,
+                text_color,
+                font_size,
+                1.0,
+                1.0,
+                None,
+                &[],
+            );
+            instrs.push(DrawInstruction::SetPos(Point::new(
+                padding_x,
+                y_pos+padding_y,
+            )));
+            let symbol_instrs = text::render_layout(
+                &symbol_layout,
+                &self.renderer,
+                gfxtag!("tokentable_symbol"),
+            );
+            instrs.extend(symbol_instrs);
+
+            // Render balance (aligned to right)
+            let balance_layout =
+                text::make_layout(&row.balance, text_color, font_size, 1.0, 1.0, None, &[]);
+            let balance_width = balance_layout.width();
+            instrs.push(DrawInstruction::SetPos(Point::new(
+                rect.w-balance_width-padding_x,
+                y_pos+padding_y,
+            )));
+            let balance_instrs =
+                text::render_layout(&balance_layout, &self.renderer, gfxtag!("tokentable_balance"));
+            instrs.extend(balance_instrs);
+
+            // Draw separator line at bottom of row
+            instrs.push(DrawInstruction::SetPos(Point::new(
+                0.,
+                y_pos+row_height,
+            )));
+            let mut mesh = MeshBuilder::new(gfxtag!("tokentable_separator"));
+            mesh.draw_line(
+                Point::new(0., 0.),
+                Point::new(rect.w+1., 0.),
+                separator_color,
+                1.,
+            );
+            let mesh = mesh.alloc(&self.renderer);
+            instrs.push(DrawInstruction::Draw(mesh.draw_untextured()));
+        }
+
+        instrs
+    }
+}
+
+#[async_trait]
+impl UIObject for TokenTable {
+    fn priority(&self) -> u32 {
+        self.priority.get()
+    }
+
+    async fn start(self: Arc<Self>, ex: ExecutorPtr) {
+        let me = Arc::downgrade(&self);
+
+        let node_ref = &self.node.upgrade().unwrap();
+
+        let method_sub = node_ref.subscribe_method_call("set_tokens").unwrap();
+        let me2 = me.clone();
+        let set_tokens_method_task = ex.spawn(async move {
+            loop {
+                let Ok(method_call) = method_sub.receive().await else {
+                    d!("Event relayer closed");
+                    return
+                };
+
+                t!("method called: set_tokens({method_call:?})");
+                assert!(method_call.send_res.is_none());
+
+                if !Self::process_set_tokens_method(&me2, method_call.data).await {
+                    return
+                };
+            }
+        });
+
+        let mut tasks = vec![set_tokens_method_task];
+        *self.tasks.lock() = tasks;
+    }
+
+    fn stop(&self) {
+        self.tasks.lock().clear();
+        *self.parent_rect.lock() = None;
+    }
+
+    async fn draw(
+        &self,
+        parent_rect: Rectangle,
+        atom: &mut PropertyAtomicGuard,
+    ) -> Option<DrawUpdate> {
+        *self.parent_rect.lock() = Some(parent_rect.clone());
+        self.rect.eval(atom, &parent_rect).ok()?;
+        let rect = self.rect.get();
+
+        let mut mesh_instrs = self.get_meshes(&rect).await;
+
+        let mut instrs = vec![DrawInstruction::ApplyView(rect)];
+        instrs.append(&mut mesh_instrs);
+
+        Some(DrawUpdate {
+            key: self.dc_key,
+            draw_calls: vec![(
+                self.dc_key,
+                DrawCall::new(instrs, vec![], self.z_index.get(), "tokentable"),
+            )],
+        })
+    }
+
+    async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        if btn != MouseButton::Left {
+            return false
+        }
+
+        let rect = self.rect.get();
+        if !rect.contains(mouse_pos) {
+            return false
+        }
+
+        if let Some(row) = self.get_row_at_y(mouse_pos.y) {
+            let mut data = vec![];
+            if let Err(e) = row.encode(&mut data) {
+                error!(target: "ui::tokentable", "Failed to encode row: {e}");
+                return false
+            }
+
+            let node_ref = self.node.upgrade().unwrap();
+            let _ = node_ref.trigger("row_click", data).await;
+            return true
+        }
+
+        false
+    }
+}
+
+impl Drop for TokenTable {
+    fn drop(&mut self) {
+        let atom = self.renderer.make_guard(gfxtag!("TokenTable::drop"));
+        self.renderer
+            .replace_draw_calls(Some(atom.batch_id), vec![(self.dc_key, Default::default())]);
+    }
+}
+
+impl std::fmt::Debug for TokenTable {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(f, "{:?}", self.node.upgrade().unwrap())
+    }
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно