Skip to main content

accumulate_client/
helpers.rs

1//! Helper utilities matching Dart SDK convenience features
2//!
3//! This module provides high-level convenience APIs similar to the Dart SDK:
4//! - SmartSigner: Auto-version tracking for transaction signing
5//! - TxBody: Factory methods for creating transaction bodies
6//! - KeyManager: Key page query and management
7//! - QuickStart: Ultra-simple API for rapid development
8//! - Polling utilities: Wait for balance, credits, transactions
9
10#![allow(clippy::unwrap_used, clippy::expect_used)]
11
12use crate::client::AccumulateClient;
13use crate::json_rpc_client::JsonRpcError;
14use crate::AccOptions;
15use ed25519_dalek::{SigningKey, Signer};
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use sha2::{Digest, Sha256};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20use url::Url;
21
22// =============================================================================
23// KERMIT TESTNET ENDPOINTS
24// =============================================================================
25
26/// Kermit public testnet V2 endpoint
27pub const KERMIT_V2: &str = "https://kermit.accumulatenetwork.io/v2";
28/// Kermit public testnet V3 endpoint
29pub const KERMIT_V3: &str = "https://kermit.accumulatenetwork.io/v3";
30
31/// Local DevNet V2 endpoint
32pub const DEVNET_V2: &str = "http://127.0.0.1:26660/v2";
33/// Local DevNet V3 endpoint
34pub const DEVNET_V3: &str = "http://127.0.0.1:26660/v3";
35
36// =============================================================================
37// TRANSACTION RESULT
38// =============================================================================
39
40/// Result of a transaction submission with wait
41#[derive(Debug, Clone)]
42pub struct TxResult {
43    /// Whether the transaction succeeded
44    pub success: bool,
45    /// Transaction ID (if successful)
46    pub txid: Option<String>,
47    /// Error message (if failed)
48    pub error: Option<String>,
49    /// Raw response data
50    pub response: Option<Value>,
51}
52
53impl TxResult {
54    /// Create a success result
55    pub fn ok(txid: String, response: Value) -> Self {
56        Self {
57            success: true,
58            txid: Some(txid),
59            error: None,
60            response: Some(response),
61        }
62    }
63
64    /// Create a failure result
65    pub fn err(error: String) -> Self {
66        Self {
67            success: false,
68            txid: None,
69            error: Some(error),
70            response: None,
71        }
72    }
73}
74
75// =============================================================================
76// TX BODY HELPERS
77// =============================================================================
78
79/// Factory methods for creating transaction bodies (matching Dart SDK TxBody)
80#[derive(Debug)]
81pub struct TxBody;
82
83impl TxBody {
84    /// Create an AddCredits transaction body
85    pub fn add_credits(recipient: &str, amount: &str, oracle: u64) -> Value {
86        json!({
87            "type": "addCredits",
88            "recipient": recipient,
89            "amount": amount,
90            "oracle": oracle
91        })
92    }
93
94    /// Create a CreateIdentity transaction body
95    pub fn create_identity(url: &str, key_book_url: &str, public_key_hash: &str) -> Value {
96        json!({
97            "type": "createIdentity",
98            "url": url,
99            "keyBookUrl": key_book_url,
100            "keyHash": public_key_hash
101        })
102    }
103
104    /// Create a CreateTokenAccount transaction body
105    pub fn create_token_account(url: &str, token_url: &str) -> Value {
106        json!({
107            "type": "createTokenAccount",
108            "url": url,
109            "tokenUrl": token_url
110        })
111    }
112
113    /// Create a CreateDataAccount transaction body
114    pub fn create_data_account(url: &str) -> Value {
115        json!({
116            "type": "createDataAccount",
117            "url": url
118        })
119    }
120
121    /// Create a CreateToken transaction body
122    pub fn create_token(url: &str, symbol: &str, precision: u64, supply_limit: Option<&str>) -> Value {
123        let mut body = json!({
124            "type": "createToken",
125            "url": url,
126            "symbol": symbol,
127            "precision": precision
128        });
129        if let Some(limit) = supply_limit {
130            body["supplyLimit"] = json!(limit);
131        }
132        body
133    }
134
135    /// Create a SendTokens transaction body for a single recipient
136    pub fn send_tokens_single(to_url: &str, amount: &str) -> Value {
137        json!({
138            "type": "sendTokens",
139            "to": [{
140                "url": to_url,
141                "amount": amount
142            }]
143        })
144    }
145
146    /// Create a SendTokens transaction body for multiple recipients
147    pub fn send_tokens_multi(recipients: &[(&str, &str)]) -> Value {
148        let to: Vec<Value> = recipients
149            .iter()
150            .map(|(url, amount)| json!({"url": url, "amount": amount}))
151            .collect();
152        json!({
153            "type": "sendTokens",
154            "to": to
155        })
156    }
157
158    /// Create an IssueTokens transaction body for a single recipient
159    pub fn issue_tokens_single(to_url: &str, amount: &str) -> Value {
160        json!({
161            "type": "issueTokens",
162            "to": [{
163                "url": to_url,
164                "amount": amount
165            }]
166        })
167    }
168
169    /// Create a WriteData transaction body
170    /// Data entries are hex-encoded and sent as a DoubleHash data entry
171    pub fn write_data(entries: &[&str]) -> Value {
172        // Convert each entry to hex string (not nested objects!)
173        let entries_hex: Vec<Value> = entries
174            .iter()
175            .map(|e| Value::String(hex::encode(e.as_bytes())))
176            .collect();
177        json!({
178            "type": "writeData",
179            "entry": {
180                "type": "doublehash",  // DataEntryType = 3
181                "data": entries_hex    // Array of hex strings
182            }
183        })
184    }
185
186    /// Create a WriteData transaction body with hex entries
187    pub fn write_data_hex(entries_hex: &[&str]) -> Value {
188        // Convert to Value::String (not nested objects!)
189        let entries: Vec<Value> = entries_hex
190            .iter()
191            .map(|e| Value::String((*e).to_string()))
192            .collect();
193        json!({
194            "type": "writeData",
195            "entry": {
196                "type": "doublehash",  // DataEntryType = 3
197                "data": entries        // Array of hex strings
198            }
199        })
200    }
201
202    /// Create a CreateKeyPage transaction body
203    pub fn create_key_page(key_hashes: &[&[u8]]) -> Value {
204        let keys: Vec<Value> = key_hashes
205            .iter()
206            .map(|h| json!({"publicKeyHash": hex::encode(h)}))
207            .collect();
208        json!({
209            "type": "createKeyPage",
210            "keys": keys
211        })
212    }
213
214    /// Create a CreateKeyBook transaction body
215    pub fn create_key_book(url: &str, public_key_hash: &str) -> Value {
216        json!({
217            "type": "createKeyBook",
218            "url": url,
219            "publicKeyHash": public_key_hash
220        })
221    }
222
223    /// Create an UpdateKeyPage transaction body to add a key
224    pub fn update_key_page_add_key(key_hash: &[u8]) -> Value {
225        json!({
226            "type": "updateKeyPage",
227            "operation": [{
228                "type": "add",
229                "entry": {
230                    "keyHash": hex::encode(key_hash)
231                }
232            }]
233        })
234    }
235
236    /// Create an UpdateKeyPage transaction body to remove a key
237    pub fn update_key_page_remove_key(key_hash: &[u8]) -> Value {
238        json!({
239            "type": "updateKeyPage",
240            "operation": [{
241                "type": "remove",
242                "entry": {
243                    "keyHash": hex::encode(key_hash)
244                }
245            }]
246        })
247    }
248
249    /// Create an UpdateKeyPage transaction body to set threshold
250    pub fn update_key_page_set_threshold(threshold: u64) -> Value {
251        json!({
252            "type": "updateKeyPage",
253            "operation": [{
254                "type": "setThreshold",
255                "threshold": threshold
256            }]
257        })
258    }
259
260    /// Create a BurnTokens transaction body
261    pub fn burn_tokens(amount: &str) -> Value {
262        json!({
263            "type": "burnTokens",
264            "amount": amount
265        })
266    }
267
268    /// Create a TransferCredits transaction body
269    pub fn transfer_credits(to_url: &str, amount: u64) -> Value {
270        json!({
271            "type": "transferCredits",
272            "to": [{
273                "url": to_url,
274                "amount": amount
275            }]
276        })
277    }
278}
279
280// =============================================================================
281// KEY PAGE STATE
282// =============================================================================
283
284/// Key page state information
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct KeyPageState {
287    /// Key page URL
288    pub url: String,
289    /// Current version
290    pub version: u64,
291    /// Credit balance
292    pub credit_balance: u64,
293    /// Accept threshold (signatures required)
294    pub accept_threshold: u64,
295    /// Keys on the page
296    pub keys: Vec<KeyEntry>,
297}
298
299/// Key entry in a key page
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct KeyEntry {
302    /// Public key hash (hex)
303    pub key_hash: String,
304    /// Delegate (if any)
305    pub delegate: Option<String>,
306}
307
308// =============================================================================
309// SMART SIGNER
310// =============================================================================
311
312/// Smart signer with auto-version tracking (matching Dart SDK SmartSigner)
313#[derive(Debug)]
314pub struct SmartSigner<'a> {
315    /// Reference to the client
316    client: &'a AccumulateClient,
317    /// Signing key
318    keypair: SigningKey,
319    /// Signer URL (key page URL)
320    signer_url: String,
321    /// Cached version (updated automatically)
322    cached_version: u64,
323}
324
325impl<'a> SmartSigner<'a> {
326    /// Create a new SmartSigner
327    pub fn new(client: &'a AccumulateClient, keypair: SigningKey, signer_url: &str) -> Self {
328        Self {
329            client,
330            keypair,
331            signer_url: signer_url.to_string(),
332            cached_version: 1,
333        }
334    }
335
336    /// Query and update the cached version
337    pub async fn refresh_version(&mut self) -> Result<u64, JsonRpcError> {
338        let params = json!({
339            "scope": &self.signer_url,
340            "query": {"queryType": "default"}
341        });
342
343        let result: Value = self.client.v3_client.call_v3("query", params).await?;
344
345        if let Some(account) = result.get("account") {
346            if let Some(version) = account.get("version").and_then(|v| v.as_u64()) {
347                self.cached_version = version;
348                return Ok(version);
349            }
350        }
351
352        Ok(self.cached_version)
353    }
354
355    /// Get the current cached version
356    pub fn version(&self) -> u64 {
357        self.cached_version
358    }
359
360    /// Sign a transaction and return the envelope
361    ///
362    /// This uses proper binary encoding matching the Go core implementation:
363    /// 1. Compute signature metadata hash (binary encoded)
364    /// 2. Use that as the transaction initiator
365    /// 3. Compute transaction hash using binary encoding
366    /// 4. Create signing preimage = SHA256(sigMdHash + txHash)
367    /// 5. Sign the preimage
368    pub fn sign(&self, principal: &str, body: &Value, memo: Option<&str>) -> Result<Value, JsonRpcError> {
369        use crate::codec::signing::{
370            compute_ed25519_signature_metadata_hash,
371            compute_transaction_hash,
372            compute_write_data_body_hash,
373            create_signing_preimage,
374            marshal_transaction_header,
375            sha256_bytes,
376        };
377
378        let timestamp = SystemTime::now()
379            .duration_since(UNIX_EPOCH)
380            .map_err(|e| JsonRpcError::General(anyhow::anyhow!("Time error: {}", e)))?
381            .as_micros() as u64;
382
383        let public_key = self.keypair.verifying_key().to_bytes();
384
385        // Step 1: Compute signature metadata hash
386        // This is used as BOTH the transaction initiator AND for signing
387        let sig_metadata_hash = compute_ed25519_signature_metadata_hash(
388            &public_key,
389            &self.signer_url,
390            self.cached_version,
391            timestamp,
392        );
393        let initiator_hex = hex::encode(&sig_metadata_hash);
394
395        // Step 2: Marshal header with initiator
396        let header_bytes = marshal_transaction_header(
397            principal,
398            &sig_metadata_hash,
399            memo,
400            None,
401        );
402
403        // Step 3 & 4: Compute transaction hash
404        // For WriteData, use special Merkle hash algorithm
405        let tx_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
406        let tx_hash = if tx_type == "writeData" || tx_type == "writeDataTo" {
407            // WriteData uses special hash: MerkleHash([bodyPartHash, entryHash])
408            let header_hash = sha256_bytes(&header_bytes);
409
410            // Extract entries from body.entry.data
411            let mut entries_hex = Vec::new();
412            if let Some(entry) = body.get("entry") {
413                if let Some(data) = entry.get("data") {
414                    if let Some(arr) = data.as_array() {
415                        for item in arr {
416                            if let Some(s) = item.as_str() {
417                                entries_hex.push(s.to_string());
418                            }
419                        }
420                    }
421                }
422            }
423            let scratch = body.get("scratch").and_then(|s| s.as_bool()).unwrap_or(false);
424            let write_to_state = body.get("writeToState").and_then(|w| w.as_bool()).unwrap_or(false);
425
426            let body_hash = compute_write_data_body_hash(&entries_hex, scratch, write_to_state);
427
428            // txHash = SHA256(SHA256(header) + bodyHash)
429            let mut combined = Vec::with_capacity(64);
430            combined.extend_from_slice(&header_hash);
431            combined.extend_from_slice(&body_hash);
432            sha256_bytes(&combined)
433        } else {
434            // Standard: SHA256(SHA256(header) + SHA256(body))
435            let body_bytes = marshal_body_to_binary(body)?;
436            compute_transaction_hash(&header_bytes, &body_bytes)
437        };
438
439        // Step 5: Create signing preimage and sign
440        let preimage = create_signing_preimage(&sig_metadata_hash, &tx_hash);
441        let signature = self.keypair.sign(&preimage);
442
443        // Build transaction JSON (for submission)
444        let mut tx = json!({
445            "header": {
446                "principal": principal,
447                "initiator": &initiator_hex
448            },
449            "body": body
450        });
451
452        if let Some(m) = memo {
453            tx["header"]["memo"] = json!(m);
454        }
455
456        // Build envelope with proper signature document
457        // V3 API expects: envelope.signatures[].transactionHash
458        let envelope = json!({
459            "transaction": [tx],
460            "signatures": [{
461                "type": "ed25519",
462                "publicKey": hex::encode(&public_key),
463                "signature": hex::encode(signature.to_bytes()),
464                "signer": &self.signer_url,
465                "signerVersion": self.cached_version,
466                "timestamp": timestamp,
467                "transactionHash": hex::encode(&tx_hash)
468            }]
469        });
470
471        Ok(envelope)
472    }
473
474    /// Sign, submit, and wait for transaction confirmation
475    pub async fn sign_submit_and_wait(
476        &mut self,
477        principal: &str,
478        body: &Value,
479        memo: Option<&str>,
480        max_attempts: u32,
481    ) -> TxResult {
482        // Refresh version before signing
483        if let Err(e) = self.refresh_version().await {
484            return TxResult::err(format!("Failed to refresh version: {}", e));
485        }
486
487        // Sign the transaction
488        let envelope = match self.sign(principal, body, memo) {
489            Ok(env) => env,
490            Err(e) => return TxResult::err(format!("Failed to sign: {}", e)),
491        };
492
493        // Submit
494        let submit_result: Result<Value, _> = self.client.v3_client.call_v3("submit", json!({
495            "envelope": envelope
496        })).await;
497
498        let response = match submit_result {
499            Ok(resp) => resp,
500            Err(e) => return TxResult::err(format!("Submit failed: {}", e)),
501        };
502
503        // Extract transaction ID
504        let txid = extract_txid(&response);
505        if txid.is_none() {
506            return TxResult::err("No transaction ID in response".to_string());
507        }
508        let txid = txid.unwrap();
509
510        // Wait for confirmation
511        // Extract just the hash for querying - format: acc://hash@unknown
512        let tx_hash = if txid.starts_with("acc://") && txid.contains('@') {
513            txid.split('@').next().unwrap_or(&txid).replace("acc://", "")
514        } else {
515            txid.clone()
516        };
517        let query_scope = format!("acc://{}@unknown", tx_hash);
518
519        for _attempt in 0..max_attempts {
520            tokio::time::sleep(Duration::from_secs(2)).await;
521
522            // Query transaction status
523            let query_result: Result<Value, _> = self.client.v3_client.call_v3("query", json!({
524                "scope": &query_scope,
525                "query": {"queryType": "default"}
526            })).await;
527
528            if let Ok(result) = query_result {
529                // Check status - can be a String or a Map (matching Dart SDK)
530                if let Some(status_value) = result.get("status") {
531                    // Case 1: Status is a simple string like "delivered" or "pending"
532                    if let Some(status_str) = status_value.as_str() {
533                        if status_str == "delivered" {
534                            return TxResult::ok(txid, response);
535                        }
536                        // "pending" - continue waiting
537                        continue;
538                    }
539
540                    // Case 2: Status is a map with delivered/failed fields
541                    if status_value.is_object() {
542                        let delivered = status_value.get("delivered")
543                            .and_then(|d| d.as_bool())
544                            .unwrap_or(false);
545
546                        if delivered {
547                            // Check for errors
548                            let failed = status_value.get("failed")
549                                .and_then(|f| f.as_bool())
550                                .unwrap_or(false);
551
552                            if failed {
553                                let error_msg = status_value.get("error")
554                                    .and_then(|e| {
555                                        if let Some(msg) = e.get("message").and_then(|m| m.as_str()) {
556                                            Some(msg.to_string())
557                                        } else {
558                                            e.as_str().map(String::from)
559                                        }
560                                    })
561                                    .unwrap_or_else(|| "Unknown error".to_string());
562                                return TxResult::err(error_msg);
563                            }
564
565                            return TxResult::ok(txid, response);
566                        }
567                    }
568                }
569            }
570        }
571
572        TxResult::err(format!("Timeout waiting for delivery: {}", txid))
573    }
574
575    /// Add a key to the key page using SmartSigner
576    pub async fn add_key(&mut self, public_key: &[u8]) -> TxResult {
577        let key_hash = sha256_hash(public_key);
578        let body = TxBody::update_key_page_add_key(&key_hash);
579        self.sign_submit_and_wait(&self.signer_url.clone(), &body, Some("Add key"), 30).await
580    }
581
582    /// Remove a key from the key page using SmartSigner
583    pub async fn remove_key(&mut self, public_key_hash: &[u8]) -> TxResult {
584        let body = TxBody::update_key_page_remove_key(public_key_hash);
585        self.sign_submit_and_wait(&self.signer_url.clone(), &body, Some("Remove key"), 30).await
586    }
587
588    /// Set the threshold for the key page
589    pub async fn set_threshold(&mut self, threshold: u64) -> TxResult {
590        let body = TxBody::update_key_page_set_threshold(threshold);
591        self.sign_submit_and_wait(&self.signer_url.clone(), &body, Some("Set threshold"), 30).await
592    }
593
594    /// Get public key hash
595    #[allow(dead_code)]
596    fn public_key_hash(&self) -> [u8; 32] {
597        sha256_hash(&self.keypair.verifying_key().to_bytes())
598    }
599}
600
601/// Marshal a JSON transaction body to binary format
602///
603/// This handles different transaction types and converts them to proper binary encoding.
604fn marshal_body_to_binary(body: &Value) -> Result<Vec<u8>, JsonRpcError> {
605    use crate::codec::signing::{
606        marshal_add_credits_body, marshal_send_tokens_body, marshal_create_identity_body,
607        marshal_create_token_account_body, marshal_create_data_account_body,
608        marshal_write_data_body, marshal_create_token_body, marshal_issue_tokens_body,
609        marshal_key_page_operation, marshal_update_key_page_body,
610        tx_types
611    };
612    use crate::codec::writer::BinaryWriter;
613
614    let tx_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
615
616    match tx_type {
617        "addCredits" => {
618            let recipient = body.get("recipient").and_then(|r| r.as_str()).unwrap_or("");
619            let amount_str = body.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
620            let amount: u64 = amount_str.parse().unwrap_or(0);
621            let oracle = body.get("oracle").and_then(|o| o.as_u64()).unwrap_or(0);
622            Ok(marshal_add_credits_body(recipient, amount, oracle))
623        }
624        "sendTokens" => {
625            let to_array = body.get("to").and_then(|t| t.as_array());
626            let mut recipients = Vec::new();
627            if let Some(to) = to_array {
628                for recipient in to {
629                    let url = recipient.get("url").and_then(|u| u.as_str()).unwrap_or("");
630                    let amount_str = recipient.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
631                    let amount: u64 = amount_str.parse().unwrap_or(0);
632                    recipients.push((url.to_string(), amount));
633                }
634            }
635            Ok(marshal_send_tokens_body(&recipients))
636        }
637        "createIdentity" => {
638            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
639            let key_book_url = body.get("keyBookUrl").and_then(|k| k.as_str()).unwrap_or("");
640            // Check both "keyHash" (preferred) and "publicKeyHash" (fallback)
641            let key_hash_hex = body.get("keyHash")
642                .or_else(|| body.get("publicKeyHash"))
643                .and_then(|k| k.as_str())
644                .unwrap_or("");
645            let key_hash = hex::decode(key_hash_hex).unwrap_or_default();
646            Ok(marshal_create_identity_body(url, &key_hash, key_book_url))
647        }
648        "createTokenAccount" => {
649            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
650            let token_url = body.get("tokenUrl").and_then(|t| t.as_str()).unwrap_or("");
651            Ok(marshal_create_token_account_body(url, token_url))
652        }
653        "createDataAccount" => {
654            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
655            Ok(marshal_create_data_account_body(url))
656        }
657        "writeData" => {
658            // Extract entries from nested entry.data structure
659            let mut entries_hex = Vec::new();
660            if let Some(entry) = body.get("entry") {
661                if let Some(data) = entry.get("data") {
662                    if let Some(arr) = data.as_array() {
663                        for item in arr {
664                            if let Some(s) = item.as_str() {
665                                entries_hex.push(s.to_string());
666                            }
667                        }
668                    }
669                }
670            }
671            let scratch = body.get("scratch").and_then(|s| s.as_bool()).unwrap_or(false);
672            let write_to_state = body.get("writeToState").and_then(|w| w.as_bool()).unwrap_or(false);
673            Ok(marshal_write_data_body(&entries_hex, scratch, write_to_state))
674        }
675        "createToken" => {
676            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
677            let symbol = body.get("symbol").and_then(|s| s.as_str()).unwrap_or("");
678            let precision = body.get("precision").and_then(|p| p.as_u64()).unwrap_or(0);
679            let supply_limit = body.get("supplyLimit")
680                .and_then(|s| s.as_str())
681                .and_then(|s| s.parse::<u64>().ok());
682            Ok(marshal_create_token_body(url, symbol, precision, supply_limit))
683        }
684        "issueTokens" => {
685            let to_array = body.get("to").and_then(|t| t.as_array());
686            let mut recipients: Vec<(&str, u64)> = Vec::new();
687            if let Some(to) = to_array {
688                for recipient in to {
689                    let url = recipient.get("url").and_then(|u| u.as_str()).unwrap_or("");
690                    let amount_str = recipient.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
691                    let amount: u64 = amount_str.parse().unwrap_or(0);
692                    recipients.push((url, amount));
693                }
694            }
695            Ok(marshal_issue_tokens_body(&recipients))
696        }
697        "updateKeyPage" => {
698            // Parse operations array from JSON
699            let op_array = body.get("operation").and_then(|o| o.as_array());
700            let mut operations: Vec<Vec<u8>> = Vec::new();
701
702            if let Some(ops) = op_array {
703                for op in ops {
704                    let op_type = op.get("type").and_then(|t| t.as_str()).unwrap_or("");
705
706                    // Extract key hash from entry.keyHash (for add/remove operations)
707                    // Go uses "keyHash" field in KeySpecParams
708                    let key_hash: Option<Vec<u8>> = op.get("entry")
709                        .and_then(|e| e.get("keyHash"))
710                        .and_then(|h| h.as_str())
711                        .and_then(|hex_str| hex::decode(hex_str).ok());
712
713                    // Extract delegate URL if present
714                    let delegate: Option<&str> = op.get("entry")
715                        .and_then(|e| e.get("delegate"))
716                        .and_then(|d| d.as_str());
717
718                    // Extract old/new key hashes for update operation
719                    let old_key_hash: Option<Vec<u8>> = op.get("oldEntry")
720                        .and_then(|e| e.get("keyHash"))
721                        .and_then(|h| h.as_str())
722                        .and_then(|hex_str| hex::decode(hex_str).ok());
723
724                    let new_key_hash: Option<Vec<u8>> = op.get("newEntry")
725                        .and_then(|e| e.get("keyHash"))
726                        .and_then(|h| h.as_str())
727                        .and_then(|hex_str| hex::decode(hex_str).ok());
728
729                    // Extract threshold for setThreshold operation
730                    let threshold: Option<u64> = op.get("threshold").and_then(|t| t.as_u64());
731
732                    // Marshal the operation
733                    let op_bytes = marshal_key_page_operation(
734                        op_type,
735                        key_hash.as_deref(),
736                        delegate,
737                        old_key_hash.as_deref(),
738                        new_key_hash.as_deref(),
739                        threshold,
740                    );
741                    operations.push(op_bytes);
742                }
743            }
744
745            Ok(marshal_update_key_page_body(&operations))
746        }
747        // For other transaction types, fall back to JSON encoding
748        // This won't produce correct signatures but allows compilation
749        _ => {
750            // Create a minimal binary encoding with just the type
751            let mut writer = BinaryWriter::new();
752
753            // Map type string to numeric type
754            let type_num = match tx_type {
755                "createIdentity" => tx_types::CREATE_IDENTITY,
756                "createTokenAccount" => tx_types::CREATE_TOKEN_ACCOUNT,
757                "createDataAccount" => tx_types::CREATE_DATA_ACCOUNT,
758                "writeData" => tx_types::WRITE_DATA,
759                "writeDataTo" => tx_types::WRITE_DATA_TO,
760                "acmeFaucet" => tx_types::ACME_FAUCET,
761                "createToken" => tx_types::CREATE_TOKEN,
762                "issueTokens" => tx_types::ISSUE_TOKENS,
763                "burnTokens" => tx_types::BURN_TOKENS,
764                "createLiteTokenAccount" => tx_types::CREATE_LITE_TOKEN_ACCOUNT,
765                "createKeyPage" => tx_types::CREATE_KEY_PAGE,
766                "createKeyBook" => tx_types::CREATE_KEY_BOOK,
767                "updateKeyPage" => tx_types::UPDATE_KEY_PAGE,
768                "updateAccountAuth" => tx_types::UPDATE_ACCOUNT_AUTH,
769                "updateKey" => tx_types::UPDATE_KEY,
770                "lockAccount" => tx_types::LOCK_ACCOUNT,
771                "transferCredits" => tx_types::TRANSFER_CREDITS,
772                "burnCredits" => tx_types::BURN_CREDITS,
773                _ => 0,
774            };
775
776            // Write field 1: Type
777            let _ = writer.write_uvarint(1);
778            let _ = writer.write_uvarint(type_num);
779
780            // Just return the type encoding - proper implementation needed
781            // for each transaction type
782            // TODO: Implement full binary encoding for all transaction types
783            Ok(writer.into_bytes())
784        }
785    }
786}
787
788// =============================================================================
789// KEY MANAGER
790// =============================================================================
791
792/// Key manager for key page operations (matching Dart SDK KeyManager)
793#[derive(Debug)]
794pub struct KeyManager<'a> {
795    /// Reference to the client
796    client: &'a AccumulateClient,
797    /// Key page URL
798    key_page_url: String,
799}
800
801impl<'a> KeyManager<'a> {
802    /// Create a new KeyManager
803    pub fn new(client: &'a AccumulateClient, key_page_url: &str) -> Self {
804        Self {
805            client,
806            key_page_url: key_page_url.to_string(),
807        }
808    }
809
810    /// Get the current key page state
811    pub async fn get_key_page_state(&self) -> Result<KeyPageState, JsonRpcError> {
812        let params = json!({
813            "scope": &self.key_page_url,
814            "query": {"queryType": "default"}
815        });
816
817        let result: Value = self.client.v3_client.call_v3("query", params).await?;
818
819        let account = result.get("account")
820            .ok_or_else(|| JsonRpcError::General(anyhow::anyhow!("No account in response")))?;
821
822        let url = account.get("url")
823            .and_then(|v| v.as_str())
824            .unwrap_or(&self.key_page_url)
825            .to_string();
826
827        let version = account.get("version")
828            .and_then(|v| v.as_u64())
829            .unwrap_or(1);
830
831        let credit_balance = account.get("creditBalance")
832            .and_then(|v| v.as_u64())
833            .unwrap_or(0);
834
835        let accept_threshold = account.get("acceptThreshold")
836            .or_else(|| account.get("threshold"))
837            .and_then(|v| v.as_u64())
838            .unwrap_or(1);
839
840        let keys: Vec<KeyEntry> = if let Some(keys_arr) = account.get("keys").and_then(|k| k.as_array()) {
841            keys_arr.iter().map(|k| {
842                let key_hash = k.get("publicKeyHash")
843                    .or_else(|| k.get("publicKey"))
844                    .and_then(|v| v.as_str())
845                    .unwrap_or("")
846                    .to_string();
847                let delegate = k.get("delegate").and_then(|v| v.as_str()).map(String::from);
848                KeyEntry { key_hash, delegate }
849            }).collect()
850        } else {
851            vec![]
852        };
853
854        Ok(KeyPageState {
855            url,
856            version,
857            credit_balance,
858            accept_threshold,
859            keys,
860        })
861    }
862}
863
864// =============================================================================
865// POLLING UTILITIES
866// =============================================================================
867
868/// Poll for account balance (matching Dart SDK pollForBalance)
869pub async fn poll_for_balance(
870    client: &AccumulateClient,
871    account_url: &str,
872    max_attempts: u32,
873) -> Option<u64> {
874    for i in 0..max_attempts {
875        let params = json!({
876            "scope": account_url,
877            "query": {"queryType": "default"}
878        });
879
880        match client.v3_client.call_v3::<Value>("query", params).await {
881            Ok(result) => {
882                if let Some(account) = result.get("account") {
883                    // Try balance as string first
884                    if let Some(balance) = account.get("balance").and_then(|b| b.as_str()) {
885                        if let Ok(bal) = balance.parse::<u64>() {
886                            if bal > 0 {
887                                return Some(bal);
888                            }
889                        }
890                    }
891                    // Try balance as number
892                    if let Some(bal) = account.get("balance").and_then(|b| b.as_u64()) {
893                        if bal > 0 {
894                            return Some(bal);
895                        }
896                    }
897                }
898                println!("  Waiting for balance... (attempt {}/{})", i + 1, max_attempts);
899            }
900            Err(_) => {
901                // Account may not exist yet
902                println!("  Account not found yet... (attempt {}/{})", i + 1, max_attempts);
903            }
904        }
905
906        if i < max_attempts - 1 {
907            tokio::time::sleep(Duration::from_secs(2)).await;
908        }
909    }
910    None
911}
912
913/// Poll for key page credits (matching Dart SDK pollForKeyPageCredits)
914pub async fn poll_for_credits(
915    client: &AccumulateClient,
916    key_page_url: &str,
917    max_attempts: u32,
918) -> Option<u64> {
919    for i in 0..max_attempts {
920        let params = json!({
921            "scope": key_page_url,
922            "query": {"queryType": "default"}
923        });
924
925        if let Ok(result) = client.v3_client.call_v3::<Value>("query", params).await {
926            if let Some(account) = result.get("account") {
927                if let Some(credits) = account.get("creditBalance").and_then(|c| c.as_u64()) {
928                    if credits > 0 {
929                        return Some(credits);
930                    }
931                }
932            }
933        }
934
935        if i < max_attempts - 1 {
936            tokio::time::sleep(Duration::from_secs(2)).await;
937        }
938    }
939    None
940}
941
942/// Wait for transaction confirmation
943pub async fn wait_for_tx(
944    client: &AccumulateClient,
945    txid: &str,
946    max_attempts: u32,
947) -> bool {
948    let tx_hash = txid.split('@').next().unwrap_or(txid).replace("acc://", "");
949
950    for _ in 0..max_attempts {
951        let params = json!({
952            "scope": format!("acc://{}@unknown", tx_hash),
953            "query": {"queryType": "default"}
954        });
955
956        if let Ok(result) = client.v3_client.call_v3::<Value>("query", params).await {
957            if let Some(status) = result.get("status") {
958                if status.get("delivered").and_then(|d| d.as_bool()).unwrap_or(false) {
959                    return true;
960                }
961            }
962        }
963
964        tokio::time::sleep(Duration::from_secs(2)).await;
965    }
966    false
967}
968
969// =============================================================================
970// WALLET STRUCT
971// =============================================================================
972
973/// Simple wallet with lite identity and token account
974#[derive(Debug, Clone)]
975pub struct Wallet {
976    /// Lite identity URL
977    pub lite_identity: String,
978    /// Lite token account URL
979    pub lite_token_account: String,
980    /// Signing key
981    keypair: SigningKey,
982}
983
984impl Wallet {
985    /// Get the signing key
986    pub fn keypair(&self) -> &SigningKey {
987        &self.keypair
988    }
989
990    /// Get the public key bytes
991    pub fn public_key(&self) -> [u8; 32] {
992        self.keypair.verifying_key().to_bytes()
993    }
994
995    /// Get the public key hash
996    pub fn public_key_hash(&self) -> [u8; 32] {
997        sha256_hash(&self.public_key())
998    }
999}
1000
1001// =============================================================================
1002// ADI INFO STRUCT
1003// =============================================================================
1004
1005/// ADI (Accumulate Digital Identity) information
1006#[derive(Debug, Clone)]
1007pub struct AdiInfo {
1008    /// ADI URL
1009    pub url: String,
1010    /// Key book URL
1011    pub key_book_url: String,
1012    /// Key page URL
1013    pub key_page_url: String,
1014    /// ADI signing key
1015    keypair: SigningKey,
1016}
1017
1018impl AdiInfo {
1019    /// Get the signing key
1020    pub fn keypair(&self) -> &SigningKey {
1021        &self.keypair
1022    }
1023
1024    /// Get the public key bytes
1025    pub fn public_key(&self) -> [u8; 32] {
1026        self.keypair.verifying_key().to_bytes()
1027    }
1028}
1029
1030// =============================================================================
1031// KEY PAGE INFO
1032// =============================================================================
1033
1034/// Key page information for QuickStart API
1035#[derive(Debug, Clone)]
1036pub struct KeyPageInfo {
1037    /// Credit balance
1038    pub credits: u64,
1039    /// Current version
1040    pub version: u64,
1041    /// Accept threshold
1042    pub threshold: u64,
1043    /// Number of keys
1044    pub key_count: usize,
1045}
1046
1047// =============================================================================
1048// QUICKSTART API
1049// =============================================================================
1050
1051/// Ultra-simple API for rapid development (matching Dart SDK QuickStart)
1052#[derive(Debug)]
1053pub struct QuickStart {
1054    /// The underlying client
1055    client: AccumulateClient,
1056}
1057
1058impl QuickStart {
1059    /// Connect to local DevNet
1060    pub async fn devnet() -> Result<Self, JsonRpcError> {
1061        let v2_url = Url::parse(DEVNET_V2).map_err(|e| {
1062            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1063        })?;
1064        let v3_url = Url::parse(DEVNET_V3).map_err(|e| {
1065            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1066        })?;
1067
1068        let client = AccumulateClient::new_with_options(v2_url, v3_url, AccOptions::default()).await?;
1069        Ok(Self { client })
1070    }
1071
1072    /// Connect to Kermit testnet
1073    pub async fn kermit() -> Result<Self, JsonRpcError> {
1074        let v2_url = Url::parse(KERMIT_V2).map_err(|e| {
1075            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1076        })?;
1077        let v3_url = Url::parse(KERMIT_V3).map_err(|e| {
1078            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1079        })?;
1080
1081        let client = AccumulateClient::new_with_options(v2_url, v3_url, AccOptions::default()).await?;
1082        Ok(Self { client })
1083    }
1084
1085    /// Connect to custom endpoints
1086    pub async fn custom(v2_endpoint: &str, v3_endpoint: &str) -> Result<Self, JsonRpcError> {
1087        let v2_url = Url::parse(v2_endpoint).map_err(|e| {
1088            JsonRpcError::General(anyhow::anyhow!("Invalid V2 URL: {}", e))
1089        })?;
1090        let v3_url = Url::parse(v3_endpoint).map_err(|e| {
1091            JsonRpcError::General(anyhow::anyhow!("Invalid V3 URL: {}", e))
1092        })?;
1093
1094        let client = AccumulateClient::new_with_options(v2_url, v3_url, AccOptions::default()).await?;
1095        Ok(Self { client })
1096    }
1097
1098    /// Get the underlying client
1099    pub fn client(&self) -> &AccumulateClient {
1100        &self.client
1101    }
1102
1103    /// Create a new wallet with lite identity and token account
1104    pub fn create_wallet(&self) -> Wallet {
1105        let keypair = AccumulateClient::generate_keypair();
1106        let public_key = keypair.verifying_key().to_bytes();
1107
1108        // Derive lite identity URL
1109        let lite_identity = derive_lite_identity_url(&public_key);
1110        let lite_token_account = format!("{}/ACME", lite_identity);
1111
1112        Wallet {
1113            lite_identity,
1114            lite_token_account,
1115            keypair,
1116        }
1117    }
1118
1119    /// Fund wallet from faucet (multiple requests) using V3 API
1120    pub async fn fund_wallet(&self, wallet: &Wallet, times: u32) -> Result<(), JsonRpcError> {
1121        for i in 0..times {
1122            let params = json!({"account": &wallet.lite_token_account});
1123            match self.client.v3_client.call_v3::<Value>("faucet", params).await {
1124                Ok(response) => {
1125                    let txid = response.get("transactionHash")
1126                        .or_else(|| response.get("txid"))
1127                        .and_then(|v| v.as_str())
1128                        .unwrap_or("submitted");
1129                    println!("  Faucet {}/{}: {}", i + 1, times, txid);
1130                }
1131                Err(e) => {
1132                    println!("  Faucet {}/{} failed: {}", i + 1, times, e);
1133                }
1134            }
1135            if i < times - 1 {
1136                tokio::time::sleep(Duration::from_secs(2)).await;
1137            }
1138        }
1139
1140        // Wait for faucet transactions to process
1141        println!("  Waiting for faucet to process...");
1142        tokio::time::sleep(Duration::from_secs(10)).await;
1143
1144        // Poll for balance to confirm account is available
1145        let balance = poll_for_balance(&self.client, &wallet.lite_token_account, 30).await;
1146        if balance.is_none() || balance == Some(0) {
1147            println!("  Warning: Account balance not confirmed yet");
1148        }
1149
1150        Ok(())
1151    }
1152
1153    /// Get account balance (polls up to 30 times)
1154    pub async fn get_balance(&self, wallet: &Wallet) -> Option<u64> {
1155        poll_for_balance(&self.client, &wallet.lite_token_account, 30).await
1156    }
1157
1158    /// Get oracle price from network status
1159    pub async fn get_oracle_price(&self) -> Result<u64, JsonRpcError> {
1160        let result: Value = self.client.v3_client.call_v3("network-status", json!({})).await?;
1161
1162        result.get("oracle")
1163            .and_then(|o| o.get("price"))
1164            .and_then(|p| p.as_u64())
1165            .ok_or_else(|| JsonRpcError::General(anyhow::anyhow!("Oracle price not found")))
1166    }
1167
1168    /// Calculate ACME amount for desired credits
1169    pub fn calculate_credits_amount(credits: u64, oracle: u64) -> u64 {
1170        // credits * 10^10 / oracle
1171        (credits as u128 * 10_000_000_000u128 / oracle as u128) as u64
1172    }
1173
1174    /// Set up an ADI (handles all the complexity)
1175    pub async fn setup_adi(&self, wallet: &Wallet, adi_name: &str) -> Result<AdiInfo, JsonRpcError> {
1176        let adi_keypair = AccumulateClient::generate_keypair();
1177        let adi_public_key = adi_keypair.verifying_key().to_bytes();
1178        let adi_key_hash = sha256_hash(&adi_public_key);
1179
1180        let identity_url = format!("acc://{}.acme", adi_name);
1181        let book_url = format!("{}/book", identity_url);
1182        let key_page_url = format!("{}/1", book_url);
1183
1184        // First, add credits to lite identity
1185        let oracle = self.get_oracle_price().await?;
1186        let credits_amount = Self::calculate_credits_amount(1000, oracle);
1187
1188        let mut signer = SmartSigner::new(&self.client, wallet.keypair.clone(), &wallet.lite_identity);
1189
1190        // Add credits to lite identity
1191        let add_credits_body = TxBody::add_credits(
1192            &wallet.lite_identity,
1193            &credits_amount.to_string(),
1194            oracle,
1195        );
1196
1197        let result = signer.sign_submit_and_wait(
1198            &wallet.lite_token_account,
1199            &add_credits_body,
1200            Some("Add credits to lite identity"),
1201            30,
1202        ).await;
1203
1204        if !result.success {
1205            return Err(JsonRpcError::General(anyhow::anyhow!(
1206                "Failed to add credits: {:?}", result.error
1207            )));
1208        }
1209
1210        // Create ADI
1211        let create_adi_body = TxBody::create_identity(
1212            &identity_url,
1213            &book_url,
1214            &hex::encode(adi_key_hash),
1215        );
1216
1217        let result = signer.sign_submit_and_wait(
1218            &wallet.lite_token_account,
1219            &create_adi_body,
1220            Some("Create ADI"),
1221            30,
1222        ).await;
1223
1224        if !result.success {
1225            return Err(JsonRpcError::General(anyhow::anyhow!(
1226                "Failed to create ADI: {:?}", result.error
1227            )));
1228        }
1229
1230        Ok(AdiInfo {
1231            url: identity_url,
1232            key_book_url: book_url,
1233            key_page_url,
1234            keypair: adi_keypair,
1235        })
1236    }
1237
1238    /// Buy credits for ADI key page (auto-fetches oracle)
1239    pub async fn buy_credits_for_adi(&self, wallet: &Wallet, adi: &AdiInfo, credits: u64) -> Result<TxResult, JsonRpcError> {
1240        let oracle = self.get_oracle_price().await?;
1241        let amount = Self::calculate_credits_amount(credits, oracle);
1242
1243        let mut signer = SmartSigner::new(&self.client, wallet.keypair.clone(), &wallet.lite_identity);
1244
1245        let body = TxBody::add_credits(&adi.key_page_url, &amount.to_string(), oracle);
1246
1247        Ok(signer.sign_submit_and_wait(
1248            &wallet.lite_token_account,
1249            &body,
1250            Some("Buy credits for ADI"),
1251            30,
1252        ).await)
1253    }
1254
1255    /// Get key page information
1256    pub async fn get_key_page_info(&self, key_page_url: &str) -> Option<KeyPageInfo> {
1257        let manager = KeyManager::new(&self.client, key_page_url);
1258        match manager.get_key_page_state().await {
1259            Ok(state) => Some(KeyPageInfo {
1260                credits: state.credit_balance,
1261                version: state.version,
1262                threshold: state.accept_threshold,
1263                key_count: state.keys.len(),
1264            }),
1265            Err(_) => None,
1266        }
1267    }
1268
1269    /// Create a token account under an ADI
1270    pub async fn create_token_account(&self, adi: &AdiInfo, account_name: &str) -> Result<TxResult, JsonRpcError> {
1271        let account_url = format!("{}/{}", adi.url, account_name);
1272        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1273
1274        let body = TxBody::create_token_account(&account_url, "acc://ACME");
1275
1276        Ok(signer.sign_submit_and_wait(
1277            &adi.url,
1278            &body,
1279            Some("Create token account"),
1280            30,
1281        ).await)
1282    }
1283
1284    /// Create a data account under an ADI
1285    pub async fn create_data_account(&self, adi: &AdiInfo, account_name: &str) -> Result<TxResult, JsonRpcError> {
1286        let account_url = format!("{}/{}", adi.url, account_name);
1287        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1288
1289        let body = TxBody::create_data_account(&account_url);
1290
1291        Ok(signer.sign_submit_and_wait(
1292            &adi.url,
1293            &body,
1294            Some("Create data account"),
1295            30,
1296        ).await)
1297    }
1298
1299    /// Write data to a data account
1300    pub async fn write_data(&self, adi: &AdiInfo, account_name: &str, entries: &[&str]) -> Result<TxResult, JsonRpcError> {
1301        let account_url = format!("{}/{}", adi.url, account_name);
1302        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1303
1304        let body = TxBody::write_data(entries);
1305
1306        Ok(signer.sign_submit_and_wait(
1307            &account_url,
1308            &body,
1309            Some("Write data"),
1310            30,
1311        ).await)
1312    }
1313
1314    /// Add a key to the ADI's key page
1315    pub async fn add_key_to_adi(&self, adi: &AdiInfo, new_keypair: &SigningKey) -> Result<TxResult, JsonRpcError> {
1316        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1317        Ok(signer.add_key(&new_keypair.verifying_key().to_bytes()).await)
1318    }
1319
1320    /// Set multi-sig threshold for the ADI's key page
1321    pub async fn set_multi_sig_threshold(&self, adi: &AdiInfo, threshold: u64) -> Result<TxResult, JsonRpcError> {
1322        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1323        Ok(signer.set_threshold(threshold).await)
1324    }
1325
1326    /// Close the client (for cleanup)
1327    pub fn close(&self) {
1328        // HTTP client cleanup is automatic in Rust
1329    }
1330}
1331
1332// =============================================================================
1333// UTILITY FUNCTIONS
1334// =============================================================================
1335
1336/// Derive lite identity URL from public key
1337///
1338/// Format: acc://[40 hex key hash][8 hex checksum]
1339/// - Key hash: SHA256(publicKey)[0..20] as hex (40 chars)
1340/// - Checksum: SHA256(keyHashHex)[28..32] as hex (8 chars)
1341///
1342/// Note: Lite addresses do NOT have .acme suffix!
1343pub fn derive_lite_identity_url(public_key: &[u8; 32]) -> String {
1344    // Get first 20 bytes of SHA256(publicKey)
1345    let hash = sha256_hash(public_key);
1346    let key_hash_20 = &hash[0..20];
1347
1348    // Convert to hex string
1349    let key_hash_hex = hex::encode(key_hash_20);
1350
1351    // Compute checksum: SHA256(keyHashHex)[28..32]
1352    let checksum_full = sha256_hash(key_hash_hex.as_bytes());
1353    let checksum_hex = hex::encode(&checksum_full[28..32]);
1354
1355    // Format: acc://[keyHash][checksum]
1356    format!("acc://{}{}", key_hash_hex, checksum_hex)
1357}
1358
1359/// Derive lite token account URL from public key
1360///
1361/// Format: acc://[40 hex key hash][8 hex checksum]/ACME
1362pub fn derive_lite_token_account_url(public_key: &[u8; 32]) -> String {
1363    let lite_identity = derive_lite_identity_url(public_key);
1364    format!("{}/ACME", lite_identity)
1365}
1366
1367/// SHA-256 hash helper
1368pub fn sha256_hash(data: &[u8]) -> [u8; 32] {
1369    let mut hasher = Sha256::new();
1370    hasher.update(data);
1371    hasher.finalize().into()
1372}
1373
1374/// Extract transaction ID from submit response
1375///
1376/// The V3 API returns a List with two entries:
1377/// - [0] = transaction result with txID like acc://hash@account/path
1378/// - [1] = signature result with txID like acc://hash@account
1379///
1380/// We prefer the second entry (signature tx) which doesn't have path suffix.
1381fn extract_txid(response: &Value) -> Option<String> {
1382    // Try array format first - this is the V3 format
1383    if let Some(arr) = response.as_array() {
1384        // Prefer second entry (signature tx) if available
1385        if arr.len() > 1 {
1386            if let Some(status) = arr[1].get("status") {
1387                if let Some(txid) = status.get("txID").and_then(|t| t.as_str()) {
1388                    return Some(txid.to_string());
1389                }
1390            }
1391        }
1392        // Fall back to first entry
1393        if let Some(first) = arr.first() {
1394            if let Some(status) = first.get("status") {
1395                if let Some(txid) = status.get("txID").and_then(|t| t.as_str()) {
1396                    return Some(txid.to_string());
1397                }
1398            }
1399        }
1400    }
1401
1402    // Try direct format
1403    response.get("txid")
1404        .or_else(|| response.get("transactionHash"))
1405        .and_then(|t| t.as_str())
1406        .map(String::from)
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use super::*;
1412
1413    #[test]
1414    fn test_derive_lite_identity_url() {
1415        let public_key = [1u8; 32];
1416        let url = derive_lite_identity_url(&public_key);
1417        assert!(url.starts_with("acc://"));
1418        // Lite URLs do NOT have .acme suffix - they have 40 hex chars + 8 hex checksum
1419        assert!(!url.ends_with(".acme"));
1420        // Format: acc://[40 hex][8 hex checksum]
1421        let path = url.strip_prefix("acc://").unwrap();
1422        assert_eq!(path.len(), 48); // 40 + 8 hex chars
1423    }
1424
1425    #[test]
1426    fn test_tx_body_add_credits() {
1427        let body = TxBody::add_credits("acc://test.acme/credits", "1000000", 5000);
1428        assert_eq!(body["type"], "addCredits");
1429        assert_eq!(body["recipient"], "acc://test.acme/credits");
1430    }
1431
1432    #[test]
1433    fn test_tx_body_send_tokens() {
1434        let body = TxBody::send_tokens_single("acc://bob.acme/tokens", "100");
1435        assert_eq!(body["type"], "sendTokens");
1436    }
1437
1438    #[test]
1439    fn test_tx_body_create_identity() {
1440        let body = TxBody::create_identity(
1441            "acc://test.acme",
1442            "acc://test.acme/book",
1443            "0123456789abcdef",
1444        );
1445        assert_eq!(body["type"], "createIdentity");
1446        assert_eq!(body["url"], "acc://test.acme");
1447    }
1448
1449    #[test]
1450    fn test_wallet_creation() {
1451        let keypair = AccumulateClient::generate_keypair();
1452        let public_key = keypair.verifying_key().to_bytes();
1453        let lite_identity = derive_lite_identity_url(&public_key);
1454        let lite_token_account = derive_lite_token_account_url(&public_key);
1455
1456        assert!(lite_identity.starts_with("acc://"));
1457        assert!(lite_token_account.contains("/ACME"));
1458    }
1459}