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 WriteDataTo transaction body with hex entries (entries already hex-encoded)
203    pub fn write_data_to_hex(recipient: &str, entries_hex: &[&str]) -> Value {
204        let entries: Vec<Value> = entries_hex
205            .iter()
206            .map(|e| Value::String((*e).to_string()))
207            .collect();
208        json!({
209            "type": "writeDataTo",
210            "recipient": recipient,
211            "entry": {
212                "type": "doublehash",
213                "data": entries
214            }
215        })
216    }
217
218    /// Create a CreateKeyPage transaction body
219    pub fn create_key_page(key_hashes: &[&[u8]]) -> Value {
220        let keys: Vec<Value> = key_hashes
221            .iter()
222            .map(|h| json!({"keyHash": hex::encode(h)}))
223            .collect();
224        json!({
225            "type": "createKeyPage",
226            "keys": keys
227        })
228    }
229
230    /// Create a CreateKeyBook transaction body
231    pub fn create_key_book(url: &str, public_key_hash: &str) -> Value {
232        json!({
233            "type": "createKeyBook",
234            "url": url,
235            "publicKeyHash": public_key_hash
236        })
237    }
238
239    /// Create an UpdateKeyPage transaction body to add a key
240    pub fn update_key_page_add_key(key_hash: &[u8]) -> Value {
241        json!({
242            "type": "updateKeyPage",
243            "operation": [{
244                "type": "add",
245                "entry": {
246                    "keyHash": hex::encode(key_hash)
247                }
248            }]
249        })
250    }
251
252    /// Create an UpdateKeyPage transaction body to remove a key
253    pub fn update_key_page_remove_key(key_hash: &[u8]) -> Value {
254        json!({
255            "type": "updateKeyPage",
256            "operation": [{
257                "type": "remove",
258                "entry": {
259                    "keyHash": hex::encode(key_hash)
260                }
261            }]
262        })
263    }
264
265    /// Create an UpdateKeyPage transaction body to set threshold
266    pub fn update_key_page_set_threshold(threshold: u64) -> Value {
267        json!({
268            "type": "updateKeyPage",
269            "operation": [{
270                "type": "setThreshold",
271                "threshold": threshold
272            }]
273        })
274    }
275
276    /// Create a BurnTokens transaction body
277    pub fn burn_tokens(amount: &str) -> Value {
278        json!({
279            "type": "burnTokens",
280            "amount": amount
281        })
282    }
283
284    /// Create a TransferCredits transaction body
285    pub fn transfer_credits(to_url: &str, amount: u64) -> Value {
286        json!({
287            "type": "transferCredits",
288            "to": [{
289                "url": to_url,
290                "amount": amount
291            }]
292        })
293    }
294
295    /// Create a BurnCredits transaction body
296    pub fn burn_credits(amount: u64) -> Value {
297        json!({
298            "type": "burnCredits",
299            "amount": amount
300        })
301    }
302
303    /// Create an UpdateKey transaction body (key rotation)
304    pub fn update_key(new_key_hash: &str) -> Value {
305        json!({
306            "type": "updateKey",
307            "newKeyHash": new_key_hash
308        })
309    }
310
311    /// Create a LockAccount transaction body
312    pub fn lock_account(height: u64) -> Value {
313        json!({
314            "type": "lockAccount",
315            "height": height
316        })
317    }
318
319    /// Create an UpdateAccountAuth transaction body
320    pub fn update_account_auth(operations: &Value) -> Value {
321        json!({
322            "type": "updateAccountAuth",
323            "operations": operations
324        })
325    }
326
327    /// Create a WriteDataTo transaction body (write data to a remote data account)
328    pub fn write_data_to(recipient: &str, entries: &[&str]) -> Value {
329        let entries_hex: Vec<Value> = entries
330            .iter()
331            .map(|e| Value::String(hex::encode(e.as_bytes())))
332            .collect();
333        json!({
334            "type": "writeDataTo",
335            "recipient": recipient,
336            "entry": {
337                "type": "doublehash",
338                "data": entries_hex
339            }
340        })
341    }
342
343    /// Create a generic UpdateKeyPage transaction body with raw operations
344    ///
345    /// For simple cases, prefer update_key_page_add_key, update_key_page_remove_key,
346    /// or update_key_page_set_threshold.
347    pub fn update_key_page(operations: &Value) -> Value {
348        json!({
349            "type": "updateKeyPage",
350            "operation": operations
351        })
352    }
353}
354
355// =============================================================================
356// KEY PAGE STATE
357// =============================================================================
358
359/// Key page state information
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct KeyPageState {
362    /// Key page URL
363    pub url: String,
364    /// Current version
365    pub version: u64,
366    /// Credit balance
367    pub credit_balance: u64,
368    /// Accept threshold (signatures required)
369    pub accept_threshold: u64,
370    /// Keys on the page
371    pub keys: Vec<KeyEntry>,
372}
373
374/// Key entry in a key page
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct KeyEntry {
377    /// Public key hash (hex)
378    pub key_hash: String,
379    /// Delegate (if any)
380    pub delegate: Option<String>,
381}
382
383// =============================================================================
384// HEADER OPTIONS
385// =============================================================================
386
387/// Optional transaction header fields for advanced transaction control.
388///
389/// These fields are included in the transaction envelope submitted to the V3 API.
390/// - `memo`: Human-readable memo text
391/// - `metadata`: Binary metadata bytes (hex-encoded in the envelope)
392/// - `expire`: Transaction expiration time
393/// - `hold_until`: Scheduled execution at a specific minor block
394/// - `authorities`: Additional signing authorities
395#[derive(Debug, Clone, Default)]
396pub struct HeaderOptions {
397    /// Human-readable memo text
398    pub memo: Option<String>,
399    /// Binary metadata bytes
400    pub metadata: Option<Vec<u8>>,
401    /// Transaction expiration options
402    pub expire: Option<crate::generated::header::ExpireOptions>,
403    /// Hold-until (delayed execution) options
404    pub hold_until: Option<crate::generated::header::HoldUntilOptions>,
405    /// Additional signing authorities (list of authority URLs)
406    pub authorities: Option<Vec<String>>,
407}
408
409// =============================================================================
410// SMART SIGNER
411// =============================================================================
412
413/// Smart signer with auto-version tracking (matching Dart SDK SmartSigner)
414#[derive(Debug)]
415pub struct SmartSigner<'a> {
416    /// Reference to the client
417    client: &'a AccumulateClient,
418    /// Signing key
419    keypair: SigningKey,
420    /// Signer URL (key page URL)
421    signer_url: String,
422    /// Cached version (updated automatically)
423    cached_version: u64,
424}
425
426impl<'a> SmartSigner<'a> {
427    /// Create a new SmartSigner
428    pub fn new(client: &'a AccumulateClient, keypair: SigningKey, signer_url: &str) -> Self {
429        Self {
430            client,
431            keypair,
432            signer_url: signer_url.to_string(),
433            cached_version: 1,
434        }
435    }
436
437    /// Query and update the cached version
438    pub async fn refresh_version(&mut self) -> Result<u64, JsonRpcError> {
439        let params = json!({
440            "scope": &self.signer_url,
441            "query": {"queryType": "default"}
442        });
443
444        let result: Value = self.client.v3_client.call_v3("query", params).await?;
445
446        if let Some(account) = result.get("account") {
447            if let Some(version) = account.get("version").and_then(|v| v.as_u64()) {
448                self.cached_version = version;
449                return Ok(version);
450            }
451        }
452
453        Ok(self.cached_version)
454    }
455
456    /// Get the current cached version
457    pub fn version(&self) -> u64 {
458        self.cached_version
459    }
460
461    /// Sign a transaction and return the envelope
462    ///
463    /// This uses proper binary encoding matching the Go core implementation:
464    /// 1. Compute signature metadata hash (binary encoded)
465    /// 2. Use that as the transaction initiator
466    /// 3. Compute transaction hash using binary encoding
467    /// 4. Create signing preimage = SHA256(sigMdHash + txHash)
468    /// 5. Sign the preimage
469    pub fn sign(&self, principal: &str, body: &Value, memo: Option<&str>) -> Result<Value, JsonRpcError> {
470        use crate::codec::signing::{
471            compute_ed25519_signature_metadata_hash,
472            compute_transaction_hash,
473            compute_write_data_body_hash,
474            compute_write_data_to_body_hash,
475            create_signing_preimage,
476            marshal_transaction_header,
477            sha256_bytes,
478        };
479
480        let timestamp = SystemTime::now()
481            .duration_since(UNIX_EPOCH)
482            .map_err(|e| JsonRpcError::General(anyhow::anyhow!("Time error: {}", e)))?
483            .as_micros() as u64;
484
485        let public_key = self.keypair.verifying_key().to_bytes();
486
487        // Step 1: Compute signature metadata hash
488        // This is used as BOTH the transaction initiator AND for signing
489        let sig_metadata_hash = compute_ed25519_signature_metadata_hash(
490            &public_key,
491            &self.signer_url,
492            self.cached_version,
493            timestamp,
494        );
495        let initiator_hex = hex::encode(&sig_metadata_hash);
496
497        // Step 2: Marshal header with initiator
498        let header_bytes = marshal_transaction_header(
499            principal,
500            &sig_metadata_hash,
501            memo,
502            None,
503        );
504
505        // Step 3 & 4: Compute transaction hash
506        // WriteData/WriteDataTo use special Merkle hash algorithm
507        let tx_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
508
509        // Helper: extract entries from body.entry.data
510        let extract_entries = |body: &Value| -> Vec<String> {
511            let mut entries_hex = Vec::new();
512            if let Some(entry) = body.get("entry") {
513                if let Some(data) = entry.get("data") {
514                    if let Some(arr) = data.as_array() {
515                        for item in arr {
516                            if let Some(s) = item.as_str() {
517                                entries_hex.push(s.to_string());
518                            }
519                        }
520                    }
521                }
522            }
523            entries_hex
524        };
525
526        let tx_hash = if tx_type == "writeData" {
527            let header_hash = sha256_bytes(&header_bytes);
528            let entries_hex = extract_entries(body);
529            let scratch = body.get("scratch").and_then(|s| s.as_bool()).unwrap_or(false);
530            let write_to_state = body.get("writeToState").and_then(|w| w.as_bool()).unwrap_or(false);
531            let body_hash = compute_write_data_body_hash(&entries_hex, scratch, write_to_state);
532            // txHash = SHA256(SHA256(header) + bodyHash)
533            let mut combined = Vec::with_capacity(64);
534            combined.extend_from_slice(&header_hash);
535            combined.extend_from_slice(&body_hash);
536            sha256_bytes(&combined)
537        } else if tx_type == "writeDataTo" {
538            let header_hash = sha256_bytes(&header_bytes);
539            let entries_hex = extract_entries(body);
540            let recipient = body.get("recipient").and_then(|r| r.as_str()).unwrap_or("");
541            let body_hash = compute_write_data_to_body_hash(recipient, &entries_hex);
542            // txHash = SHA256(SHA256(header) + bodyHash)
543            let mut combined = Vec::with_capacity(64);
544            combined.extend_from_slice(&header_hash);
545            combined.extend_from_slice(&body_hash);
546            sha256_bytes(&combined)
547        } else {
548            // Standard: SHA256(SHA256(header) + SHA256(body))
549            let body_bytes = marshal_body_to_binary(body)?;
550            compute_transaction_hash(&header_bytes, &body_bytes)
551        };
552
553        // Step 5: Create signing preimage and sign
554        let preimage = create_signing_preimage(&sig_metadata_hash, &tx_hash);
555        let signature = self.keypair.sign(&preimage);
556
557        // Build transaction JSON (for submission)
558        let mut tx = json!({
559            "header": {
560                "principal": principal,
561                "initiator": &initiator_hex
562            },
563            "body": body
564        });
565
566        if let Some(m) = memo {
567            tx["header"]["memo"] = json!(m);
568        }
569
570        // Build envelope with proper signature document
571        // V3 API expects: envelope.signatures[].transactionHash
572        let envelope = json!({
573            "transaction": [tx],
574            "signatures": [{
575                "type": "ed25519",
576                "publicKey": hex::encode(&public_key),
577                "signature": hex::encode(signature.to_bytes()),
578                "signer": &self.signer_url,
579                "signerVersion": self.cached_version,
580                "timestamp": timestamp,
581                "transactionHash": hex::encode(&tx_hash)
582            }]
583        });
584
585        Ok(envelope)
586    }
587
588    /// Sign, submit, and wait for transaction confirmation
589    pub async fn sign_submit_and_wait(
590        &mut self,
591        principal: &str,
592        body: &Value,
593        memo: Option<&str>,
594        max_attempts: u32,
595    ) -> TxResult {
596        // Refresh version before signing
597        if let Err(e) = self.refresh_version().await {
598            return TxResult::err(format!("Failed to refresh version: {}", e));
599        }
600
601        // Sign the transaction
602        let envelope = match self.sign(principal, body, memo) {
603            Ok(env) => env,
604            Err(e) => return TxResult::err(format!("Failed to sign: {}", e)),
605        };
606
607        // Submit
608        let submit_result: Result<Value, _> = self.client.v3_client.call_v3("submit", json!({
609            "envelope": envelope
610        })).await;
611
612        let response = match submit_result {
613            Ok(resp) => resp,
614            Err(e) => return TxResult::err(format!("Submit failed: {}", e)),
615        };
616
617        // Extract transaction ID
618        let txid = extract_txid(&response);
619        if txid.is_none() {
620            return TxResult::err("No transaction ID in response".to_string());
621        }
622        let txid = txid.unwrap();
623
624        // Wait for confirmation
625        // Extract just the hash for querying - format: acc://hash@unknown
626        let tx_hash = if txid.starts_with("acc://") && txid.contains('@') {
627            txid.split('@').next().unwrap_or(&txid).replace("acc://", "")
628        } else {
629            txid.clone()
630        };
631        let query_scope = format!("acc://{}@unknown", tx_hash);
632
633        for _attempt in 0..max_attempts {
634            tokio::time::sleep(Duration::from_secs(2)).await;
635
636            // Query transaction status
637            let query_result: Result<Value, _> = self.client.v3_client.call_v3("query", json!({
638                "scope": &query_scope,
639                "query": {"queryType": "default"}
640            })).await;
641
642            if let Ok(result) = query_result {
643                // Check status - can be a String or a Map (matching Dart SDK)
644                if let Some(status_value) = result.get("status") {
645                    // Case 1: Status is a simple string like "delivered" or "pending"
646                    if let Some(status_str) = status_value.as_str() {
647                        if status_str == "delivered" {
648                            return TxResult::ok(txid, response);
649                        }
650                        // "pending" - continue waiting
651                        continue;
652                    }
653
654                    // Case 2: Status is a map with delivered/failed fields
655                    if status_value.is_object() {
656                        let delivered = status_value.get("delivered")
657                            .and_then(|d| d.as_bool())
658                            .unwrap_or(false);
659
660                        if delivered {
661                            // Check for errors
662                            let failed = status_value.get("failed")
663                                .and_then(|f| f.as_bool())
664                                .unwrap_or(false);
665
666                            if failed {
667                                let error_msg = status_value.get("error")
668                                    .and_then(|e| {
669                                        if let Some(msg) = e.get("message").and_then(|m| m.as_str()) {
670                                            Some(msg.to_string())
671                                        } else {
672                                            e.as_str().map(String::from)
673                                        }
674                                    })
675                                    .unwrap_or_else(|| "Unknown error".to_string());
676                                return TxResult::err(error_msg);
677                            }
678
679                            return TxResult::ok(txid, response);
680                        }
681                    }
682                }
683            }
684        }
685
686        TxResult::err(format!("Timeout waiting for delivery: {}", txid))
687    }
688
689    /// Add a key to the key page using SmartSigner
690    pub async fn add_key(&mut self, public_key: &[u8]) -> TxResult {
691        let key_hash = sha256_hash(public_key);
692        let body = TxBody::update_key_page_add_key(&key_hash);
693        self.sign_submit_and_wait(&self.signer_url.clone(), &body, Some("Add key"), 30).await
694    }
695
696    /// Remove a key from the key page using SmartSigner
697    pub async fn remove_key(&mut self, public_key_hash: &[u8]) -> TxResult {
698        let body = TxBody::update_key_page_remove_key(public_key_hash);
699        self.sign_submit_and_wait(&self.signer_url.clone(), &body, Some("Remove key"), 30).await
700    }
701
702    /// Set the threshold for the key page
703    pub async fn set_threshold(&mut self, threshold: u64) -> TxResult {
704        let body = TxBody::update_key_page_set_threshold(threshold);
705        self.sign_submit_and_wait(&self.signer_url.clone(), &body, Some("Set threshold"), 30).await
706    }
707
708    /// Get public key hash
709    #[allow(dead_code)]
710    fn public_key_hash(&self) -> [u8; 32] {
711        sha256_hash(&self.keypair.verifying_key().to_bytes())
712    }
713
714    /// Sign a transaction with full header options and return the envelope.
715    ///
716    /// Like [`sign`], but accepts a [`HeaderOptions`] struct for specifying
717    /// memo, metadata, expire, hold_until, and authorities.
718    pub fn sign_with_options(
719        &self,
720        principal: &str,
721        body: &Value,
722        options: &HeaderOptions,
723    ) -> Result<Value, JsonRpcError> {
724        use crate::codec::signing::{
725            compute_ed25519_signature_metadata_hash,
726            compute_transaction_hash,
727            compute_write_data_body_hash,
728            compute_write_data_to_body_hash,
729            create_signing_preimage,
730            marshal_transaction_header_full,
731            HeaderBinaryOptions,
732            sha256_bytes,
733        };
734
735        let timestamp = SystemTime::now()
736            .duration_since(UNIX_EPOCH)
737            .map_err(|e| JsonRpcError::General(anyhow::anyhow!("Time error: {}", e)))?
738            .as_micros() as u64;
739
740        let public_key = self.keypair.verifying_key().to_bytes();
741
742        // Step 1: Compute signature metadata hash
743        let sig_metadata_hash = compute_ed25519_signature_metadata_hash(
744            &public_key,
745            &self.signer_url,
746            self.cached_version,
747            timestamp,
748        );
749        let initiator_hex = hex::encode(&sig_metadata_hash);
750
751        // Step 2: Marshal header with initiator, memo, metadata, and extended options
752        let memo_ref = options.memo.as_deref();
753        let metadata_ref = options.metadata.as_deref();
754
755        // Build extended binary options for fields 5-7
756        let has_extended = options.expire.is_some()
757            || options.hold_until.is_some()
758            || options.authorities.is_some();
759
760        let extended = if has_extended {
761            Some(HeaderBinaryOptions {
762                expire_at_time: options.expire.as_ref().and_then(|e| e.at_time.map(|t| t as i64)),
763                hold_until_minor_block: options.hold_until.as_ref().and_then(|h| h.minor_block),
764                authorities: options.authorities.clone(),
765            })
766        } else {
767            None
768        };
769
770        let header_bytes = marshal_transaction_header_full(
771            principal,
772            &sig_metadata_hash,
773            memo_ref,
774            metadata_ref,
775            extended.as_ref(),
776        );
777
778        // Step 3 & 4: Compute transaction hash
779        let tx_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
780
781        // Helper: extract entries from body.entry.data
782        let extract_entries = |body: &Value| -> Vec<String> {
783            let mut entries_hex = Vec::new();
784            if let Some(entry) = body.get("entry") {
785                if let Some(data) = entry.get("data") {
786                    if let Some(arr) = data.as_array() {
787                        for item in arr {
788                            if let Some(s) = item.as_str() {
789                                entries_hex.push(s.to_string());
790                            }
791                        }
792                    }
793                }
794            }
795            entries_hex
796        };
797
798        let tx_hash = if tx_type == "writeData" {
799            let header_hash = sha256_bytes(&header_bytes);
800            let entries_hex = extract_entries(body);
801            let scratch = body.get("scratch").and_then(|s| s.as_bool()).unwrap_or(false);
802            let write_to_state = body.get("writeToState").and_then(|w| w.as_bool()).unwrap_or(false);
803            let body_hash = compute_write_data_body_hash(&entries_hex, scratch, write_to_state);
804            let mut combined = Vec::with_capacity(64);
805            combined.extend_from_slice(&header_hash);
806            combined.extend_from_slice(&body_hash);
807            sha256_bytes(&combined)
808        } else if tx_type == "writeDataTo" {
809            let header_hash = sha256_bytes(&header_bytes);
810            let entries_hex = extract_entries(body);
811            let recipient = body.get("recipient").and_then(|r| r.as_str()).unwrap_or("");
812            let body_hash = compute_write_data_to_body_hash(recipient, &entries_hex);
813            let mut combined = Vec::with_capacity(64);
814            combined.extend_from_slice(&header_hash);
815            combined.extend_from_slice(&body_hash);
816            sha256_bytes(&combined)
817        } else {
818            let body_bytes = marshal_body_to_binary(body)?;
819            compute_transaction_hash(&header_bytes, &body_bytes)
820        };
821
822        // Step 5: Create signing preimage and sign
823        let preimage = create_signing_preimage(&sig_metadata_hash, &tx_hash);
824        let signature = self.keypair.sign(&preimage);
825
826        // Build transaction JSON (for submission)
827        let mut tx = json!({
828            "header": {
829                "principal": principal,
830                "initiator": &initiator_hex
831            },
832            "body": body
833        });
834
835        // Add optional header fields
836        if let Some(ref m) = options.memo {
837            tx["header"]["memo"] = json!(m);
838        }
839        if let Some(ref md) = options.metadata {
840            tx["header"]["metadata"] = json!(hex::encode(md));
841        }
842        if let Some(ref expire) = options.expire {
843            if let Some(at_time) = expire.at_time {
844                // V3 API expects atTime as an RFC 3339 / ISO 8601 timestamp string
845                let dt = chrono::DateTime::from_timestamp(at_time as i64, 0)
846                    .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
847                tx["header"]["expire"] = json!({ "atTime": dt.to_rfc3339() });
848            }
849        }
850        if let Some(ref hold) = options.hold_until {
851            if let Some(minor_block) = hold.minor_block {
852                tx["header"]["holdUntil"] = json!({ "minorBlock": minor_block });
853            }
854        }
855        if let Some(ref auths) = options.authorities {
856            tx["header"]["authorities"] = json!(auths);
857        }
858
859        // Build envelope
860        let envelope = json!({
861            "transaction": [tx],
862            "signatures": [{
863                "type": "ed25519",
864                "publicKey": hex::encode(&public_key),
865                "signature": hex::encode(signature.to_bytes()),
866                "signer": &self.signer_url,
867                "signerVersion": self.cached_version,
868                "timestamp": timestamp,
869                "transactionHash": hex::encode(&tx_hash)
870            }]
871        });
872
873        Ok(envelope)
874    }
875
876    /// Sign, submit, and wait for transaction confirmation with full header options.
877    ///
878    /// Like [`sign_submit_and_wait`], but accepts a [`HeaderOptions`] struct.
879    pub async fn sign_submit_and_wait_with_options(
880        &mut self,
881        principal: &str,
882        body: &Value,
883        options: &HeaderOptions,
884        max_attempts: u32,
885    ) -> TxResult {
886        // Refresh version before signing
887        if let Err(e) = self.refresh_version().await {
888            return TxResult::err(format!("Failed to refresh version: {}", e));
889        }
890
891        // Sign the transaction with options
892        let envelope = match self.sign_with_options(principal, body, options) {
893            Ok(env) => env,
894            Err(e) => return TxResult::err(format!("Failed to sign: {}", e)),
895        };
896
897        // Submit
898        let submit_result: Result<Value, _> = self.client.v3_client.call_v3("submit", json!({
899            "envelope": envelope
900        })).await;
901
902        let response = match submit_result {
903            Ok(resp) => resp,
904            Err(e) => return TxResult::err(format!("Submit failed: {}", e)),
905        };
906
907        // Extract transaction ID
908        let txid = extract_txid(&response);
909        if txid.is_none() {
910            return TxResult::err("No transaction ID in response".to_string());
911        }
912        let txid = txid.unwrap();
913
914        // Wait for confirmation
915        let tx_hash = if txid.starts_with("acc://") && txid.contains('@') {
916            txid.split('@').next().unwrap_or(&txid).replace("acc://", "")
917        } else {
918            txid.clone()
919        };
920        let query_scope = format!("acc://{}@unknown", tx_hash);
921
922        for _attempt in 0..max_attempts {
923            tokio::time::sleep(Duration::from_secs(2)).await;
924
925            let query_result: Result<Value, _> = self.client.v3_client.call_v3("query", json!({
926                "scope": &query_scope,
927                "query": {"queryType": "default"}
928            })).await;
929
930            if let Ok(result) = query_result {
931                if let Some(status_value) = result.get("status") {
932                    if let Some(status_str) = status_value.as_str() {
933                        if status_str == "delivered" {
934                            return TxResult::ok(txid, response);
935                        }
936                        continue;
937                    }
938                    if status_value.is_object() {
939                        let delivered = status_value.get("delivered")
940                            .and_then(|d| d.as_bool())
941                            .unwrap_or(false);
942                        if delivered {
943                            let failed = status_value.get("failed")
944                                .and_then(|f| f.as_bool())
945                                .unwrap_or(false);
946                            if failed {
947                                let error_msg = status_value.get("error")
948                                    .and_then(|e| {
949                                        if let Some(msg) = e.get("message").and_then(|m| m.as_str()) {
950                                            Some(msg.to_string())
951                                        } else {
952                                            e.as_str().map(String::from)
953                                        }
954                                    })
955                                    .unwrap_or_else(|| "Unknown error".to_string());
956                                return TxResult::err(error_msg);
957                            }
958                            return TxResult::ok(txid, response);
959                        }
960                    }
961                }
962            }
963        }
964
965        TxResult::err(format!("Timeout waiting for delivery: {}", txid))
966    }
967}
968
969/// Marshal a JSON transaction body to binary format
970///
971/// This handles different transaction types and converts them to proper binary encoding.
972fn marshal_body_to_binary(body: &Value) -> Result<Vec<u8>, JsonRpcError> {
973    use crate::codec::signing::{
974        marshal_add_credits_body, marshal_send_tokens_body, marshal_create_identity_body,
975        marshal_create_token_account_body, marshal_create_data_account_body,
976        marshal_write_data_body, marshal_create_token_body, marshal_issue_tokens_body,
977        marshal_key_page_operation, marshal_update_key_page_body,
978        marshal_create_key_book_body, marshal_create_key_page_body,
979        marshal_burn_tokens_body, marshal_update_key_body,
980        marshal_burn_credits_body, marshal_transfer_credits_body,
981        marshal_write_data_to_body, marshal_lock_account_body,
982        marshal_update_account_auth_body,
983        tx_types
984    };
985    use crate::codec::writer::BinaryWriter;
986
987    let tx_type = body.get("type").and_then(|t| t.as_str()).unwrap_or("");
988
989    match tx_type {
990        "addCredits" => {
991            let recipient = body.get("recipient").and_then(|r| r.as_str()).unwrap_or("");
992            let amount_str = body.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
993            let amount: u64 = amount_str.parse().unwrap_or(0);
994            let oracle = body.get("oracle").and_then(|o| o.as_u64()).unwrap_or(0);
995            Ok(marshal_add_credits_body(recipient, amount, oracle))
996        }
997        "sendTokens" => {
998            let to_array = body.get("to").and_then(|t| t.as_array());
999            let mut recipients = Vec::new();
1000            if let Some(to) = to_array {
1001                for recipient in to {
1002                    let url = recipient.get("url").and_then(|u| u.as_str()).unwrap_or("");
1003                    let amount_str = recipient.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
1004                    let amount: u64 = amount_str.parse().unwrap_or(0);
1005                    recipients.push((url.to_string(), amount));
1006                }
1007            }
1008            Ok(marshal_send_tokens_body(&recipients))
1009        }
1010        "createIdentity" => {
1011            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
1012            let key_book_url = body.get("keyBookUrl").and_then(|k| k.as_str()).unwrap_or("");
1013            // Check both "keyHash" (preferred) and "publicKeyHash" (fallback)
1014            let key_hash_hex = body.get("keyHash")
1015                .or_else(|| body.get("publicKeyHash"))
1016                .and_then(|k| k.as_str())
1017                .unwrap_or("");
1018            let key_hash = hex::decode(key_hash_hex).unwrap_or_default();
1019            Ok(marshal_create_identity_body(url, &key_hash, key_book_url))
1020        }
1021        "createTokenAccount" => {
1022            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
1023            let token_url = body.get("tokenUrl").and_then(|t| t.as_str()).unwrap_or("");
1024            Ok(marshal_create_token_account_body(url, token_url))
1025        }
1026        "createDataAccount" => {
1027            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
1028            Ok(marshal_create_data_account_body(url))
1029        }
1030        "writeData" => {
1031            // Extract entries from nested entry.data structure
1032            let mut entries_hex = Vec::new();
1033            if let Some(entry) = body.get("entry") {
1034                if let Some(data) = entry.get("data") {
1035                    if let Some(arr) = data.as_array() {
1036                        for item in arr {
1037                            if let Some(s) = item.as_str() {
1038                                entries_hex.push(s.to_string());
1039                            }
1040                        }
1041                    }
1042                }
1043            }
1044            let scratch = body.get("scratch").and_then(|s| s.as_bool()).unwrap_or(false);
1045            let write_to_state = body.get("writeToState").and_then(|w| w.as_bool()).unwrap_or(false);
1046            Ok(marshal_write_data_body(&entries_hex, scratch, write_to_state))
1047        }
1048        "createToken" => {
1049            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
1050            let symbol = body.get("symbol").and_then(|s| s.as_str()).unwrap_or("");
1051            let precision = body.get("precision").and_then(|p| p.as_u64()).unwrap_or(0);
1052            let supply_limit = body.get("supplyLimit")
1053                .and_then(|s| s.as_str())
1054                .and_then(|s| s.parse::<u64>().ok());
1055            Ok(marshal_create_token_body(url, symbol, precision, supply_limit))
1056        }
1057        "issueTokens" => {
1058            let to_array = body.get("to").and_then(|t| t.as_array());
1059            let mut recipients: Vec<(&str, u64)> = Vec::new();
1060            if let Some(to) = to_array {
1061                for recipient in to {
1062                    let url = recipient.get("url").and_then(|u| u.as_str()).unwrap_or("");
1063                    let amount_str = recipient.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
1064                    let amount: u64 = amount_str.parse().unwrap_or(0);
1065                    recipients.push((url, amount));
1066                }
1067            }
1068            Ok(marshal_issue_tokens_body(&recipients))
1069        }
1070        "burnTokens" => {
1071            let amount_str = body.get("amount").and_then(|a| a.as_str()).unwrap_or("0");
1072            let amount: u64 = amount_str.parse().unwrap_or(0);
1073            Ok(marshal_burn_tokens_body(amount))
1074        }
1075        "createKeyBook" => {
1076            let url = body.get("url").and_then(|u| u.as_str()).unwrap_or("");
1077            let key_hash_hex = body.get("publicKeyHash")
1078                .or_else(|| body.get("keyHash"))
1079                .and_then(|k| k.as_str())
1080                .unwrap_or("");
1081            let key_hash = hex::decode(key_hash_hex).unwrap_or_default();
1082            Ok(marshal_create_key_book_body(url, &key_hash))
1083        }
1084        "createKeyPage" => {
1085            let keys_array = body.get("keys").and_then(|k| k.as_array());
1086            let mut key_hashes: Vec<Vec<u8>> = Vec::new();
1087            if let Some(keys) = keys_array {
1088                for key in keys {
1089                    let key_hash_hex = key.get("keyHash")
1090                        .or_else(|| key.get("publicKeyHash"))
1091                        .and_then(|k| k.as_str())
1092                        .unwrap_or("");
1093                    let key_hash = hex::decode(key_hash_hex).unwrap_or_default();
1094                    key_hashes.push(key_hash);
1095                }
1096            }
1097            Ok(marshal_create_key_page_body(&key_hashes))
1098        }
1099        "updateKey" => {
1100            let new_key_hash_hex = body.get("newKeyHash")
1101                .or_else(|| body.get("newKey"))
1102                .and_then(|k| k.as_str())
1103                .unwrap_or("");
1104            let new_key_hash = hex::decode(new_key_hash_hex).unwrap_or_default();
1105            Ok(marshal_update_key_body(&new_key_hash))
1106        }
1107        "updateKeyPage" => {
1108            // Parse operations array from JSON
1109            let op_array = body.get("operation").and_then(|o| o.as_array());
1110            let mut operations: Vec<Vec<u8>> = Vec::new();
1111
1112            if let Some(ops) = op_array {
1113                for op in ops {
1114                    let op_type = op.get("type").and_then(|t| t.as_str()).unwrap_or("");
1115
1116                    // Extract key hash from entry.keyHash (for add/remove operations)
1117                    // Go uses "keyHash" field in KeySpecParams
1118                    let key_hash: Option<Vec<u8>> = op.get("entry")
1119                        .and_then(|e| e.get("keyHash"))
1120                        .and_then(|h| h.as_str())
1121                        .and_then(|hex_str| hex::decode(hex_str).ok());
1122
1123                    // Extract delegate URL if present
1124                    let delegate: Option<&str> = op.get("entry")
1125                        .and_then(|e| e.get("delegate"))
1126                        .and_then(|d| d.as_str());
1127
1128                    // Extract old/new key hashes for update operation
1129                    let old_key_hash: Option<Vec<u8>> = op.get("oldEntry")
1130                        .and_then(|e| e.get("keyHash"))
1131                        .and_then(|h| h.as_str())
1132                        .and_then(|hex_str| hex::decode(hex_str).ok());
1133
1134                    let new_key_hash: Option<Vec<u8>> = op.get("newEntry")
1135                        .and_then(|e| e.get("keyHash"))
1136                        .and_then(|h| h.as_str())
1137                        .and_then(|hex_str| hex::decode(hex_str).ok());
1138
1139                    // Extract threshold for setThreshold operation
1140                    let threshold: Option<u64> = op.get("threshold").and_then(|t| t.as_u64());
1141
1142                    // Marshal the operation
1143                    let op_bytes = marshal_key_page_operation(
1144                        op_type,
1145                        key_hash.as_deref(),
1146                        delegate,
1147                        old_key_hash.as_deref(),
1148                        new_key_hash.as_deref(),
1149                        threshold,
1150                    );
1151                    operations.push(op_bytes);
1152                }
1153            }
1154
1155            Ok(marshal_update_key_page_body(&operations))
1156        }
1157        "burnCredits" => {
1158            let amount = body.get("amount").and_then(|a| a.as_u64()).unwrap_or(0);
1159            Ok(marshal_burn_credits_body(amount))
1160        }
1161        "transferCredits" => {
1162            let to_array = body.get("to").and_then(|t| t.as_array());
1163            let mut recipients: Vec<(&str, u64)> = Vec::new();
1164            if let Some(to) = to_array {
1165                for recipient in to {
1166                    let url = recipient.get("url").and_then(|u| u.as_str()).unwrap_or("");
1167                    let amount = recipient.get("amount").and_then(|a| a.as_u64()).unwrap_or(0);
1168                    recipients.push((url, amount));
1169                }
1170            }
1171            Ok(marshal_transfer_credits_body(&recipients))
1172        }
1173        "writeDataTo" => {
1174            let recipient = body.get("recipient").and_then(|r| r.as_str()).unwrap_or("");
1175            let mut entries_hex = Vec::new();
1176            if let Some(entry) = body.get("entry") {
1177                if let Some(data) = entry.get("data") {
1178                    if let Some(arr) = data.as_array() {
1179                        for item in arr {
1180                            if let Some(s) = item.as_str() {
1181                                entries_hex.push(s.to_string());
1182                            }
1183                        }
1184                    }
1185                }
1186            }
1187            Ok(marshal_write_data_to_body(recipient, &entries_hex))
1188        }
1189        "lockAccount" => {
1190            let height = body.get("height").and_then(|h| h.as_u64()).unwrap_or(0);
1191            Ok(marshal_lock_account_body(height))
1192        }
1193        "updateAccountAuth" => {
1194            let ops_array = body.get("operations").and_then(|o| o.as_array());
1195            let mut operations: Vec<(&str, &str)> = Vec::new();
1196            if let Some(ops) = ops_array {
1197                for op in ops {
1198                    let op_type = op.get("type").and_then(|t| t.as_str()).unwrap_or("");
1199                    let authority = op.get("authority").and_then(|a| a.as_str()).unwrap_or("");
1200                    operations.push((op_type, authority));
1201                }
1202            }
1203            Ok(marshal_update_account_auth_body(&operations))
1204        }
1205        // For other transaction types, fall back to JSON encoding
1206        // This won't produce correct signatures but allows compilation
1207        _ => {
1208            // Create a minimal binary encoding with just the type
1209            let mut writer = BinaryWriter::new();
1210
1211            // Map type string to numeric type
1212            let type_num = match tx_type {
1213                "createIdentity" => tx_types::CREATE_IDENTITY,
1214                "createTokenAccount" => tx_types::CREATE_TOKEN_ACCOUNT,
1215                "createDataAccount" => tx_types::CREATE_DATA_ACCOUNT,
1216                "writeData" => tx_types::WRITE_DATA,
1217                "writeDataTo" => tx_types::WRITE_DATA_TO,
1218                "acmeFaucet" => tx_types::ACME_FAUCET,
1219                "createToken" => tx_types::CREATE_TOKEN,
1220                "issueTokens" => tx_types::ISSUE_TOKENS,
1221                "burnTokens" => tx_types::BURN_TOKENS,
1222                "createLiteTokenAccount" => tx_types::CREATE_LITE_TOKEN_ACCOUNT,
1223                "createKeyPage" => tx_types::CREATE_KEY_PAGE,
1224                "createKeyBook" => tx_types::CREATE_KEY_BOOK,
1225                "updateKeyPage" => tx_types::UPDATE_KEY_PAGE,
1226                "updateAccountAuth" => tx_types::UPDATE_ACCOUNT_AUTH,
1227                "updateKey" => tx_types::UPDATE_KEY,
1228                "lockAccount" => tx_types::LOCK_ACCOUNT,
1229                "transferCredits" => tx_types::TRANSFER_CREDITS,
1230                "burnCredits" => tx_types::BURN_CREDITS,
1231                _ => 0,
1232            };
1233
1234            // Write field 1: Type
1235            let _ = writer.write_uvarint(1);
1236            let _ = writer.write_uvarint(type_num);
1237
1238            // Just return the type encoding - proper implementation needed
1239            // for each transaction type
1240            // TODO: Implement full binary encoding for all transaction types
1241            Ok(writer.into_bytes())
1242        }
1243    }
1244}
1245
1246// =============================================================================
1247// KEY MANAGER
1248// =============================================================================
1249
1250/// Key manager for key page operations (matching Dart SDK KeyManager)
1251#[derive(Debug)]
1252pub struct KeyManager<'a> {
1253    /// Reference to the client
1254    client: &'a AccumulateClient,
1255    /// Key page URL
1256    key_page_url: String,
1257}
1258
1259impl<'a> KeyManager<'a> {
1260    /// Create a new KeyManager
1261    pub fn new(client: &'a AccumulateClient, key_page_url: &str) -> Self {
1262        Self {
1263            client,
1264            key_page_url: key_page_url.to_string(),
1265        }
1266    }
1267
1268    /// Get the current key page state
1269    pub async fn get_key_page_state(&self) -> Result<KeyPageState, JsonRpcError> {
1270        let params = json!({
1271            "scope": &self.key_page_url,
1272            "query": {"queryType": "default"}
1273        });
1274
1275        let result: Value = self.client.v3_client.call_v3("query", params).await?;
1276
1277        let account = result.get("account")
1278            .ok_or_else(|| JsonRpcError::General(anyhow::anyhow!("No account in response")))?;
1279
1280        let url = account.get("url")
1281            .and_then(|v| v.as_str())
1282            .unwrap_or(&self.key_page_url)
1283            .to_string();
1284
1285        let version = account.get("version")
1286            .and_then(|v| v.as_u64())
1287            .unwrap_or(1);
1288
1289        let credit_balance = account.get("creditBalance")
1290            .and_then(|v| v.as_u64())
1291            .unwrap_or(0);
1292
1293        let accept_threshold = account.get("acceptThreshold")
1294            .or_else(|| account.get("threshold"))
1295            .and_then(|v| v.as_u64())
1296            .unwrap_or(1);
1297
1298        let keys: Vec<KeyEntry> = if let Some(keys_arr) = account.get("keys").and_then(|k| k.as_array()) {
1299            keys_arr.iter().map(|k| {
1300                let key_hash = k.get("publicKeyHash")
1301                    .or_else(|| k.get("publicKey"))
1302                    .and_then(|v| v.as_str())
1303                    .unwrap_or("")
1304                    .to_string();
1305                let delegate = k.get("delegate").and_then(|v| v.as_str()).map(String::from);
1306                KeyEntry { key_hash, delegate }
1307            }).collect()
1308        } else {
1309            vec![]
1310        };
1311
1312        Ok(KeyPageState {
1313            url,
1314            version,
1315            credit_balance,
1316            accept_threshold,
1317            keys,
1318        })
1319    }
1320}
1321
1322// =============================================================================
1323// POLLING UTILITIES
1324// =============================================================================
1325
1326/// Poll for account balance (matching Dart SDK pollForBalance)
1327pub async fn poll_for_balance(
1328    client: &AccumulateClient,
1329    account_url: &str,
1330    max_attempts: u32,
1331) -> Option<u64> {
1332    for i in 0..max_attempts {
1333        let params = json!({
1334            "scope": account_url,
1335            "query": {"queryType": "default"}
1336        });
1337
1338        match client.v3_client.call_v3::<Value>("query", params).await {
1339            Ok(result) => {
1340                if let Some(account) = result.get("account") {
1341                    // Try balance as string first
1342                    if let Some(balance) = account.get("balance").and_then(|b| b.as_str()) {
1343                        if let Ok(bal) = balance.parse::<u64>() {
1344                            if bal > 0 {
1345                                return Some(bal);
1346                            }
1347                        }
1348                    }
1349                    // Try balance as number
1350                    if let Some(bal) = account.get("balance").and_then(|b| b.as_u64()) {
1351                        if bal > 0 {
1352                            return Some(bal);
1353                        }
1354                    }
1355                }
1356                println!("  Waiting for balance... (attempt {}/{})", i + 1, max_attempts);
1357            }
1358            Err(_) => {
1359                // Account may not exist yet
1360                println!("  Account not found yet... (attempt {}/{})", i + 1, max_attempts);
1361            }
1362        }
1363
1364        if i < max_attempts - 1 {
1365            tokio::time::sleep(Duration::from_secs(2)).await;
1366        }
1367    }
1368    None
1369}
1370
1371/// Poll for key page credits (matching Dart SDK pollForKeyPageCredits)
1372pub async fn poll_for_credits(
1373    client: &AccumulateClient,
1374    key_page_url: &str,
1375    max_attempts: u32,
1376) -> Option<u64> {
1377    for i in 0..max_attempts {
1378        let params = json!({
1379            "scope": key_page_url,
1380            "query": {"queryType": "default"}
1381        });
1382
1383        if let Ok(result) = client.v3_client.call_v3::<Value>("query", params).await {
1384            if let Some(account) = result.get("account") {
1385                if let Some(credits) = account.get("creditBalance").and_then(|c| c.as_u64()) {
1386                    if credits > 0 {
1387                        return Some(credits);
1388                    }
1389                }
1390            }
1391        }
1392
1393        if i < max_attempts - 1 {
1394            tokio::time::sleep(Duration::from_secs(2)).await;
1395        }
1396    }
1397    None
1398}
1399
1400/// Wait for transaction confirmation
1401pub async fn wait_for_tx(
1402    client: &AccumulateClient,
1403    txid: &str,
1404    max_attempts: u32,
1405) -> bool {
1406    let tx_hash = txid.split('@').next().unwrap_or(txid).replace("acc://", "");
1407
1408    for _ in 0..max_attempts {
1409        let params = json!({
1410            "scope": format!("acc://{}@unknown", tx_hash),
1411            "query": {"queryType": "default"}
1412        });
1413
1414        if let Ok(result) = client.v3_client.call_v3::<Value>("query", params).await {
1415            if let Some(status) = result.get("status") {
1416                if status.get("delivered").and_then(|d| d.as_bool()).unwrap_or(false) {
1417                    return true;
1418                }
1419            }
1420        }
1421
1422        tokio::time::sleep(Duration::from_secs(2)).await;
1423    }
1424    false
1425}
1426
1427// =============================================================================
1428// WALLET STRUCT
1429// =============================================================================
1430
1431/// Simple wallet with lite identity and token account
1432#[derive(Debug, Clone)]
1433pub struct Wallet {
1434    /// Lite identity URL
1435    pub lite_identity: String,
1436    /// Lite token account URL
1437    pub lite_token_account: String,
1438    /// Signing key
1439    keypair: SigningKey,
1440}
1441
1442impl Wallet {
1443    /// Get the signing key
1444    pub fn keypair(&self) -> &SigningKey {
1445        &self.keypair
1446    }
1447
1448    /// Get the public key bytes
1449    pub fn public_key(&self) -> [u8; 32] {
1450        self.keypair.verifying_key().to_bytes()
1451    }
1452
1453    /// Get the public key hash
1454    pub fn public_key_hash(&self) -> [u8; 32] {
1455        sha256_hash(&self.public_key())
1456    }
1457}
1458
1459// =============================================================================
1460// ADI INFO STRUCT
1461// =============================================================================
1462
1463/// ADI (Accumulate Digital Identity) information
1464#[derive(Debug, Clone)]
1465pub struct AdiInfo {
1466    /// ADI URL
1467    pub url: String,
1468    /// Key book URL
1469    pub key_book_url: String,
1470    /// Key page URL
1471    pub key_page_url: String,
1472    /// ADI signing key
1473    keypair: SigningKey,
1474}
1475
1476impl AdiInfo {
1477    /// Get the signing key
1478    pub fn keypair(&self) -> &SigningKey {
1479        &self.keypair
1480    }
1481
1482    /// Get the public key bytes
1483    pub fn public_key(&self) -> [u8; 32] {
1484        self.keypair.verifying_key().to_bytes()
1485    }
1486}
1487
1488// =============================================================================
1489// KEY PAGE INFO
1490// =============================================================================
1491
1492/// Key page information for QuickStart API
1493#[derive(Debug, Clone)]
1494pub struct KeyPageInfo {
1495    /// Credit balance
1496    pub credits: u64,
1497    /// Current version
1498    pub version: u64,
1499    /// Accept threshold
1500    pub threshold: u64,
1501    /// Number of keys
1502    pub key_count: usize,
1503}
1504
1505// =============================================================================
1506// QUICKSTART API
1507// =============================================================================
1508
1509/// Ultra-simple API for rapid development (matching Dart SDK QuickStart)
1510#[derive(Debug)]
1511pub struct QuickStart {
1512    /// The underlying client
1513    client: AccumulateClient,
1514}
1515
1516impl QuickStart {
1517    /// Connect to local DevNet
1518    pub async fn devnet() -> Result<Self, JsonRpcError> {
1519        let v2_url = Url::parse(DEVNET_V2).map_err(|e| {
1520            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1521        })?;
1522        let v3_url = Url::parse(DEVNET_V3).map_err(|e| {
1523            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1524        })?;
1525
1526        let client = AccumulateClient::new_with_options(v2_url, v3_url, AccOptions::default()).await?;
1527        Ok(Self { client })
1528    }
1529
1530    /// Connect to Kermit testnet
1531    pub async fn kermit() -> Result<Self, JsonRpcError> {
1532        let v2_url = Url::parse(KERMIT_V2).map_err(|e| {
1533            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1534        })?;
1535        let v3_url = Url::parse(KERMIT_V3).map_err(|e| {
1536            JsonRpcError::General(anyhow::anyhow!("Invalid URL: {}", e))
1537        })?;
1538
1539        let client = AccumulateClient::new_with_options(v2_url, v3_url, AccOptions::default()).await?;
1540        Ok(Self { client })
1541    }
1542
1543    /// Connect to custom endpoints
1544    pub async fn custom(v2_endpoint: &str, v3_endpoint: &str) -> Result<Self, JsonRpcError> {
1545        let v2_url = Url::parse(v2_endpoint).map_err(|e| {
1546            JsonRpcError::General(anyhow::anyhow!("Invalid V2 URL: {}", e))
1547        })?;
1548        let v3_url = Url::parse(v3_endpoint).map_err(|e| {
1549            JsonRpcError::General(anyhow::anyhow!("Invalid V3 URL: {}", e))
1550        })?;
1551
1552        let client = AccumulateClient::new_with_options(v2_url, v3_url, AccOptions::default()).await?;
1553        Ok(Self { client })
1554    }
1555
1556    /// Get the underlying client
1557    pub fn client(&self) -> &AccumulateClient {
1558        &self.client
1559    }
1560
1561    /// Create a new wallet with lite identity and token account
1562    pub fn create_wallet(&self) -> Wallet {
1563        let keypair = AccumulateClient::generate_keypair();
1564        let public_key = keypair.verifying_key().to_bytes();
1565
1566        // Derive lite identity URL
1567        let lite_identity = derive_lite_identity_url(&public_key);
1568        let lite_token_account = format!("{}/ACME", lite_identity);
1569
1570        Wallet {
1571            lite_identity,
1572            lite_token_account,
1573            keypair,
1574        }
1575    }
1576
1577    /// Fund wallet from faucet (multiple requests) using V3 API
1578    pub async fn fund_wallet(&self, wallet: &Wallet, times: u32) -> Result<(), JsonRpcError> {
1579        for i in 0..times {
1580            let params = json!({"account": &wallet.lite_token_account});
1581            match self.client.v3_client.call_v3::<Value>("faucet", params).await {
1582                Ok(response) => {
1583                    let txid = response.get("transactionHash")
1584                        .or_else(|| response.get("txid"))
1585                        .and_then(|v| v.as_str())
1586                        .unwrap_or("submitted");
1587                    println!("  Faucet {}/{}: {}", i + 1, times, txid);
1588                }
1589                Err(e) => {
1590                    println!("  Faucet {}/{} failed: {}", i + 1, times, e);
1591                }
1592            }
1593            if i < times - 1 {
1594                tokio::time::sleep(Duration::from_secs(2)).await;
1595            }
1596        }
1597
1598        // Wait for faucet transactions to process
1599        println!("  Waiting for faucet to process...");
1600        tokio::time::sleep(Duration::from_secs(10)).await;
1601
1602        // Poll for balance to confirm account is available
1603        let balance = poll_for_balance(&self.client, &wallet.lite_token_account, 30).await;
1604        if balance.is_none() || balance == Some(0) {
1605            println!("  Warning: Account balance not confirmed yet");
1606        }
1607
1608        Ok(())
1609    }
1610
1611    /// Get account balance (polls up to 30 times)
1612    pub async fn get_balance(&self, wallet: &Wallet) -> Option<u64> {
1613        poll_for_balance(&self.client, &wallet.lite_token_account, 30).await
1614    }
1615
1616    /// Get oracle price from network status
1617    pub async fn get_oracle_price(&self) -> Result<u64, JsonRpcError> {
1618        let result: Value = self.client.v3_client.call_v3("network-status", json!({})).await?;
1619
1620        result.get("oracle")
1621            .and_then(|o| o.get("price"))
1622            .and_then(|p| p.as_u64())
1623            .ok_or_else(|| JsonRpcError::General(anyhow::anyhow!("Oracle price not found")))
1624    }
1625
1626    /// Calculate ACME amount for desired credits
1627    pub fn calculate_credits_amount(credits: u64, oracle: u64) -> u64 {
1628        // credits * 10^10 / oracle
1629        (credits as u128 * 10_000_000_000u128 / oracle as u128) as u64
1630    }
1631
1632    /// Set up an ADI (handles all the complexity)
1633    pub async fn setup_adi(&self, wallet: &Wallet, adi_name: &str) -> Result<AdiInfo, JsonRpcError> {
1634        let adi_keypair = AccumulateClient::generate_keypair();
1635        let adi_public_key = adi_keypair.verifying_key().to_bytes();
1636        let adi_key_hash = sha256_hash(&adi_public_key);
1637
1638        let identity_url = format!("acc://{}.acme", adi_name);
1639        let book_url = format!("{}/book", identity_url);
1640        let key_page_url = format!("{}/1", book_url);
1641
1642        // First, add credits to lite identity
1643        let oracle = self.get_oracle_price().await?;
1644        let credits_amount = Self::calculate_credits_amount(1000, oracle);
1645
1646        let mut signer = SmartSigner::new(&self.client, wallet.keypair.clone(), &wallet.lite_identity);
1647
1648        // Add credits to lite identity
1649        let add_credits_body = TxBody::add_credits(
1650            &wallet.lite_identity,
1651            &credits_amount.to_string(),
1652            oracle,
1653        );
1654
1655        let result = signer.sign_submit_and_wait(
1656            &wallet.lite_token_account,
1657            &add_credits_body,
1658            Some("Add credits to lite identity"),
1659            30,
1660        ).await;
1661
1662        if !result.success {
1663            return Err(JsonRpcError::General(anyhow::anyhow!(
1664                "Failed to add credits: {:?}", result.error
1665            )));
1666        }
1667
1668        // Create ADI
1669        let create_adi_body = TxBody::create_identity(
1670            &identity_url,
1671            &book_url,
1672            &hex::encode(adi_key_hash),
1673        );
1674
1675        let result = signer.sign_submit_and_wait(
1676            &wallet.lite_token_account,
1677            &create_adi_body,
1678            Some("Create ADI"),
1679            30,
1680        ).await;
1681
1682        if !result.success {
1683            return Err(JsonRpcError::General(anyhow::anyhow!(
1684                "Failed to create ADI: {:?}", result.error
1685            )));
1686        }
1687
1688        Ok(AdiInfo {
1689            url: identity_url,
1690            key_book_url: book_url,
1691            key_page_url,
1692            keypair: adi_keypair,
1693        })
1694    }
1695
1696    /// Buy credits for ADI key page (auto-fetches oracle)
1697    pub async fn buy_credits_for_adi(&self, wallet: &Wallet, adi: &AdiInfo, credits: u64) -> Result<TxResult, JsonRpcError> {
1698        let oracle = self.get_oracle_price().await?;
1699        let amount = Self::calculate_credits_amount(credits, oracle);
1700
1701        let mut signer = SmartSigner::new(&self.client, wallet.keypair.clone(), &wallet.lite_identity);
1702
1703        let body = TxBody::add_credits(&adi.key_page_url, &amount.to_string(), oracle);
1704
1705        Ok(signer.sign_submit_and_wait(
1706            &wallet.lite_token_account,
1707            &body,
1708            Some("Buy credits for ADI"),
1709            30,
1710        ).await)
1711    }
1712
1713    /// Get key page information
1714    pub async fn get_key_page_info(&self, key_page_url: &str) -> Option<KeyPageInfo> {
1715        let manager = KeyManager::new(&self.client, key_page_url);
1716        match manager.get_key_page_state().await {
1717            Ok(state) => Some(KeyPageInfo {
1718                credits: state.credit_balance,
1719                version: state.version,
1720                threshold: state.accept_threshold,
1721                key_count: state.keys.len(),
1722            }),
1723            Err(_) => None,
1724        }
1725    }
1726
1727    /// Create a token account under an ADI
1728    pub async fn create_token_account(&self, adi: &AdiInfo, account_name: &str) -> Result<TxResult, JsonRpcError> {
1729        let account_url = format!("{}/{}", adi.url, account_name);
1730        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1731
1732        let body = TxBody::create_token_account(&account_url, "acc://ACME");
1733
1734        Ok(signer.sign_submit_and_wait(
1735            &adi.url,
1736            &body,
1737            Some("Create token account"),
1738            30,
1739        ).await)
1740    }
1741
1742    /// Create a data account under an ADI
1743    pub async fn create_data_account(&self, adi: &AdiInfo, account_name: &str) -> Result<TxResult, JsonRpcError> {
1744        let account_url = format!("{}/{}", adi.url, account_name);
1745        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1746
1747        let body = TxBody::create_data_account(&account_url);
1748
1749        Ok(signer.sign_submit_and_wait(
1750            &adi.url,
1751            &body,
1752            Some("Create data account"),
1753            30,
1754        ).await)
1755    }
1756
1757    /// Write data to a data account
1758    pub async fn write_data(&self, adi: &AdiInfo, account_name: &str, entries: &[&str]) -> Result<TxResult, JsonRpcError> {
1759        let account_url = format!("{}/{}", adi.url, account_name);
1760        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1761
1762        let body = TxBody::write_data(entries);
1763
1764        Ok(signer.sign_submit_and_wait(
1765            &account_url,
1766            &body,
1767            Some("Write data"),
1768            30,
1769        ).await)
1770    }
1771
1772    /// Add a key to the ADI's key page
1773    pub async fn add_key_to_adi(&self, adi: &AdiInfo, new_keypair: &SigningKey) -> Result<TxResult, JsonRpcError> {
1774        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1775        Ok(signer.add_key(&new_keypair.verifying_key().to_bytes()).await)
1776    }
1777
1778    /// Set multi-sig threshold for the ADI's key page
1779    pub async fn set_multi_sig_threshold(&self, adi: &AdiInfo, threshold: u64) -> Result<TxResult, JsonRpcError> {
1780        let mut signer = SmartSigner::new(&self.client, adi.keypair.clone(), &adi.key_page_url);
1781        Ok(signer.set_threshold(threshold).await)
1782    }
1783
1784    /// Close the client (for cleanup)
1785    pub fn close(&self) {
1786        // HTTP client cleanup is automatic in Rust
1787    }
1788}
1789
1790// =============================================================================
1791// UTILITY FUNCTIONS
1792// =============================================================================
1793
1794/// Derive lite identity URL from public key
1795///
1796/// Format: acc://[40 hex key hash][8 hex checksum]
1797/// - Key hash: SHA256(publicKey)[0..20] as hex (40 chars)
1798/// - Checksum: SHA256(keyHashHex)[28..32] as hex (8 chars)
1799///
1800/// Note: Lite addresses do NOT have .acme suffix!
1801pub fn derive_lite_identity_url(public_key: &[u8; 32]) -> String {
1802    // Get first 20 bytes of SHA256(publicKey)
1803    let hash = sha256_hash(public_key);
1804    let key_hash_20 = &hash[0..20];
1805
1806    // Convert to hex string
1807    let key_hash_hex = hex::encode(key_hash_20);
1808
1809    // Compute checksum: SHA256(keyHashHex)[28..32]
1810    let checksum_full = sha256_hash(key_hash_hex.as_bytes());
1811    let checksum_hex = hex::encode(&checksum_full[28..32]);
1812
1813    // Format: acc://[keyHash][checksum]
1814    format!("acc://{}{}", key_hash_hex, checksum_hex)
1815}
1816
1817/// Derive lite token account URL from public key
1818///
1819/// Format: acc://[40 hex key hash][8 hex checksum]/ACME
1820pub fn derive_lite_token_account_url(public_key: &[u8; 32]) -> String {
1821    let lite_identity = derive_lite_identity_url(public_key);
1822    format!("{}/ACME", lite_identity)
1823}
1824
1825/// SHA-256 hash helper
1826pub fn sha256_hash(data: &[u8]) -> [u8; 32] {
1827    let mut hasher = Sha256::new();
1828    hasher.update(data);
1829    hasher.finalize().into()
1830}
1831
1832/// Extract transaction ID from submit response
1833///
1834/// The V3 API returns a List with two entries:
1835/// - [0] = transaction result with txID like acc://hash@account/path
1836/// - [1] = signature result with txID like acc://hash@account
1837///
1838/// We prefer the second entry (signature tx) which doesn't have path suffix.
1839fn extract_txid(response: &Value) -> Option<String> {
1840    // Try array format first - this is the V3 format
1841    if let Some(arr) = response.as_array() {
1842        // Prefer second entry (signature tx) if available
1843        if arr.len() > 1 {
1844            if let Some(status) = arr[1].get("status") {
1845                if let Some(txid) = status.get("txID").and_then(|t| t.as_str()) {
1846                    return Some(txid.to_string());
1847                }
1848            }
1849        }
1850        // Fall back to first entry
1851        if let Some(first) = arr.first() {
1852            if let Some(status) = first.get("status") {
1853                if let Some(txid) = status.get("txID").and_then(|t| t.as_str()) {
1854                    return Some(txid.to_string());
1855                }
1856            }
1857        }
1858    }
1859
1860    // Try direct format
1861    response.get("txid")
1862        .or_else(|| response.get("transactionHash"))
1863        .and_then(|t| t.as_str())
1864        .map(String::from)
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869    use super::*;
1870
1871    #[test]
1872    fn test_derive_lite_identity_url() {
1873        let public_key = [1u8; 32];
1874        let url = derive_lite_identity_url(&public_key);
1875        assert!(url.starts_with("acc://"));
1876        // Lite URLs do NOT have .acme suffix - they have 40 hex chars + 8 hex checksum
1877        assert!(!url.ends_with(".acme"));
1878        // Format: acc://[40 hex][8 hex checksum]
1879        let path = url.strip_prefix("acc://").unwrap();
1880        assert_eq!(path.len(), 48); // 40 + 8 hex chars
1881    }
1882
1883    #[test]
1884    fn test_tx_body_add_credits() {
1885        let body = TxBody::add_credits("acc://test.acme/credits", "1000000", 5000);
1886        assert_eq!(body["type"], "addCredits");
1887        assert_eq!(body["recipient"], "acc://test.acme/credits");
1888    }
1889
1890    #[test]
1891    fn test_tx_body_send_tokens() {
1892        let body = TxBody::send_tokens_single("acc://bob.acme/tokens", "100");
1893        assert_eq!(body["type"], "sendTokens");
1894    }
1895
1896    #[test]
1897    fn test_tx_body_create_identity() {
1898        let body = TxBody::create_identity(
1899            "acc://test.acme",
1900            "acc://test.acme/book",
1901            "0123456789abcdef",
1902        );
1903        assert_eq!(body["type"], "createIdentity");
1904        assert_eq!(body["url"], "acc://test.acme");
1905    }
1906
1907    #[test]
1908    fn test_wallet_creation() {
1909        let keypair = AccumulateClient::generate_keypair();
1910        let public_key = keypair.verifying_key().to_bytes();
1911        let lite_identity = derive_lite_identity_url(&public_key);
1912        let lite_token_account = derive_lite_token_account_url(&public_key);
1913
1914        assert!(lite_identity.starts_with("acc://"));
1915        assert!(lite_token_account.contains("/ACME"));
1916    }
1917}