From a662f7da2ec9951796af3d3bc94ace45fdd906ce Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Thu, 26 Feb 2026 11:56:42 +0000 Subject: [PATCH] Fix subaddress creation to use chain tip for CARROT detection, add CARROT subaddress derivation --- crates/salvium-crypto/src/subaddress.rs | 36 ++++++ crates/salvium-wallet/src/sync.rs | 3 + crates/salvium-wallet/src/wallet.rs | 88 +++++++++---- docs/wallet-sync-spec.md | 160 +++++++++++++++++++----- 4 files changed, 232 insertions(+), 55 deletions(-) diff --git a/crates/salvium-crypto/src/subaddress.rs b/crates/salvium-crypto/src/subaddress.rs index d78e2eb..15eabbd 100644 --- a/crates/salvium-crypto/src/subaddress.rs +++ b/crates/salvium-crypto/src/subaddress.rs @@ -224,6 +224,42 @@ fn carrot_subaddress_spend_pubkey( EdwardsPoint::vartime_multiscalar_mul(&[k_subscal], &[*account_spend_pt]) } +/// Derive the CARROT subaddress spend and view public keys for a given index. +/// +/// Returns `(spend_pubkey, view_pubkey)` as 32-byte arrays. +/// +/// For (0,0): spend = K_s, view = primary_address_view_pubkey (k_vi * G). +/// For other indices: spend = k^j_subscal * K_s, view = k^j_subscal * K_v. +pub fn carrot_derive_subaddress_keys( + account_spend_pubkey: &[u8; 32], + account_view_pubkey: &[u8; 32], + primary_address_view_pubkey: &[u8; 32], + generate_address_secret: &[u8; 32], + major: u32, + minor: u32, +) -> ([u8; 32], [u8; 32]) { + if major == 0 && minor == 0 { + return (*account_spend_pubkey, *primary_address_view_pubkey); + } + + let spend_pt = match CompressedEdwardsY(*account_spend_pubkey).decompress() { + Some(pt) => pt, + None => return (*account_spend_pubkey, *account_view_pubkey), + }; + let view_pt = match CompressedEdwardsY(*account_view_pubkey).decompress() { + Some(pt) => pt, + None => return (*account_spend_pubkey, *account_view_pubkey), + }; + + let s_gen = carrot_index_extension_generator(generate_address_secret, major, minor); + let k_subscal = carrot_subaddress_scalar(account_spend_pubkey, &s_gen, major, minor); + + let sub_spend = EdwardsPoint::vartime_multiscalar_mul(&[k_subscal], &[spend_pt]); + let sub_view = EdwardsPoint::vartime_multiscalar_mul(&[k_subscal], &[view_pt]); + + (sub_spend.compress().to_bytes(), sub_view.compress().to_bytes()) +} + /// Generate the full CARROT subaddress map as a flat binary buffer. /// /// Iterates major 0..=major_count, minor 0..=minor_count. diff --git a/crates/salvium-wallet/src/sync.rs b/crates/salvium-wallet/src/sync.rs index 1283f40..ae3c132 100644 --- a/crates/salvium-wallet/src/sync.rs +++ b/crates/salvium-wallet/src/sync.rs @@ -134,6 +134,9 @@ impl SyncEngine { let sync_height = { let db = db.lock().map_err(|e| WalletError::Storage(e.to_string()))?; + // Persist chain tip so non-sync code (e.g. subaddress creation) + // can determine the active hardfork without daemon access. + let _ = db.set_attribute("chain_tip_height", &top_block.to_string()); db.get_sync_height() .map_err(|e| WalletError::Storage(e.to_string()))? }; diff --git a/crates/salvium-wallet/src/wallet.rs b/crates/salvium-wallet/src/wallet.rs index c16c934..18a46ba 100644 --- a/crates/salvium-wallet/src/wallet.rs +++ b/crates/salvium-wallet/src/wallet.rs @@ -706,38 +706,80 @@ impl Wallet { } /// Derive the address string for a subaddress at (major, minor). + /// + /// Produces a CARROT address if the CARROT hard fork is active at the + /// chain tip height, otherwise falls back to legacy CryptoNote. + /// The chain tip is persisted in the DB by the sync engine. + #[cfg(not(target_arch = "wasm32"))] fn derive_subaddress(&self, major: u32, minor: u32) -> Result { use salvium_types::address::create_address_raw; + use salvium_types::consensus::is_carrot_active; use salvium_types::constants::{AddressFormat, AddressType}; + let chain_tip = { + let db = self + .db + .lock() + .map_err(|e| WalletError::Storage(e.to_string()))?; + db.get_attribute("chain_tip_height") + .map_err(|e| WalletError::Storage(e.to_string()))? + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }; + + let use_carrot = is_carrot_active(chain_tip, self.keys.network) + && !self.keys.carrot.is_empty(); + if major == 0 && minor == 0 { - // Primary address. - return self - .keys - .cn_address() - .map_err(|e| WalletError::InvalidAddress(e.to_string())); + return if use_carrot { + self.keys + .carrot_address() + .map_err(|e| WalletError::InvalidAddress(e.to_string())) + } else { + self.keys + .cn_address() + .map_err(|e| WalletError::InvalidAddress(e.to_string())) + }; } - // Derive the subaddress spend public key. - let spend_pub = salvium_crypto::subaddress::cn_derive_subaddress_spend_pubkey( - &self.keys.cn.spend_public_key, - &self.keys.cn.view_secret_key, - major, - minor, - ); + if use_carrot { + let (spend_pub, view_pub) = + salvium_crypto::subaddress::carrot_derive_subaddress_keys( + &self.keys.carrot.account_spend_pubkey, + &self.keys.carrot.account_view_pubkey, + &self.keys.carrot.primary_address_view_pubkey, + &self.keys.carrot.generate_address_secret, + major, + minor, + ); - // Subaddresses use the main view public key. - let addr = create_address_raw( - self.keys.network, - AddressFormat::Legacy, - AddressType::Subaddress, - &spend_pub, - &self.keys.cn.view_public_key, - None, - ) - .map_err(|e| WalletError::InvalidAddress(e.to_string()))?; + create_address_raw( + self.keys.network, + AddressFormat::Carrot, + AddressType::Subaddress, + &spend_pub, + &view_pub, + None, + ) + .map_err(|e| WalletError::InvalidAddress(e.to_string())) + } else { + let spend_pub = salvium_crypto::subaddress::cn_derive_subaddress_spend_pubkey( + &self.keys.cn.spend_public_key, + &self.keys.cn.view_secret_key, + major, + minor, + ); - Ok(addr) + create_address_raw( + self.keys.network, + AddressFormat::Legacy, + AddressType::Subaddress, + &spend_pub, + &self.keys.cn.view_public_key, + None, + ) + .map_err(|e| WalletError::InvalidAddress(e.to_string())) + } } // ── Integrated addresses ──────────────────────────────────────────── diff --git a/docs/wallet-sync-spec.md b/docs/wallet-sync-spec.md index f0840e8..c9c400d 100644 --- a/docs/wallet-sync-spec.md +++ b/docs/wallet-sync-spec.md @@ -2,6 +2,10 @@ How to use the salvium-rs FFI library to create a wallet, sync it, and read data. +**All JSON fields are camelCase.** Both query inputs and response outputs use camelCase +(e.g. `isConfirmed`, `incomingAmount`, `blockHeight`). This is enforced by +`#[serde(rename_all = "camelCase")]` on every struct. + ## 1. Initialization ```c @@ -92,6 +96,19 @@ int rc = salvium_wallet_sync(wallet, daemon, my_callback); - Stores matched outputs and transactions in the wallet database - Updates sync height per-block for crash safety +### Cancellation + +From another thread, call: + +```c +int rc = salvium_wallet_stop_sync(wallet); +// rc: 0 = success, -1 = error +``` + +The sync loop stops before the next batch and returns error code -1. +The callback fires event type 6 (Cancelled) with the height reached. +After cancelling, you can `salvium_wallet_reset_sync_height` and restart. + ### Callback (optional, may be NULL) ```c @@ -111,6 +128,7 @@ void my_callback(int type, uint64_t cur, uint64_t target, uint32_t outs, const c case 3: printf("Reorg: %llu -> %llu\n", cur, target); break; case 4: printf("Error: %s\n", msg); break; case 5: printf("Parse error at %llu: %s\n", cur, msg); break; + case 6: printf("Cancelled at %llu\n", cur); break; } } ``` @@ -123,6 +141,7 @@ void my_callback(int type, uint64_t cur, uint64_t target, uint32_t outs, const c | Reorg | 3 | When chain fork detected | `current_height` = old tip, `target_height` = fork point | | Error | 4 | On RPC/network error | `error_msg` = description | | ParseError | 5 | On block parse failure | `current_height` = block height, `error_msg` = details | +| Cancelled | 6 | When `stop_sync` called | `current_height` = height reached | ### Incremental sync @@ -140,80 +159,142 @@ salvium_wallet_sync(wallet, daemon, callback); // full rescan ```c // Single asset char* json = salvium_wallet_get_balance(wallet, "SAL", 0); -// Returns: {"balance":"123456789","unlocked_balance":"100000000","locked_balance":"23456789"} +// Returns: {"balance":"123456789","unlockedBalance":"100000000","lockedBalance":"23456789"} // Amounts are atomic units (1 SAL = 100,000,000 atomic) // CALLER MUST FREE: salvium_string_free(json); // All assets char* all = salvium_wallet_get_all_balances(wallet, 0); -// Returns: {"SAL":{"balance":"...","unlocked_balance":"...","locked_balance":"..."}, ...} +// Returns: {"SAL":{"balance":"...","unlockedBalance":"...","lockedBalance":"..."}, ...} salvium_string_free(all); ``` +**BalanceResult fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `balance` | string | Total balance (atomic units) | +| `unlockedBalance` | string | Spendable balance (atomic units) | +| `lockedBalance` | string | Locked/immature balance (atomic units) | + **You MUST sync before querying balances.** Without sync, the balance will be 0. ## 6. Query Transactions ```c // All confirmed transfers -char* txs = salvium_wallet_get_transfers(wallet, "{\"is_confirmed\":true}"); +char* txs = salvium_wallet_get_transfers(wallet, "{\"isConfirmed\":true}"); // Only incoming -char* txs = salvium_wallet_get_transfers(wallet, "{\"is_incoming\":true}"); +char* txs = salvium_wallet_get_transfers(wallet, "{\"isIncoming\":true}"); // Height range -char* txs = salvium_wallet_get_transfers(wallet, "{\"min_height\":100000,\"max_height\":200000}"); +char* txs = salvium_wallet_get_transfers(wallet, "{\"minHeight\":100000,\"maxHeight\":200000}"); // Specific tx hash -char* txs = salvium_wallet_get_transfers(wallet, "{\"tx_hash\":\"abc123...\"}"); +char* txs = salvium_wallet_get_transfers(wallet, "{\"txHash\":\"abc123...\"}"); // Returns JSON array. ALWAYS free: salvium_string_free(txs); ``` -**TxQuery fields (all optional):** +**TxQuery fields (all optional, camelCase):** | Field | Type | Description | |-------|------|-------------| -| `is_incoming` | bool | Filter incoming transfers | -| `is_outgoing` | bool | Filter outgoing transfers | -| `is_confirmed` | bool | Filter confirmed (in-block) | -| `in_pool` | bool | Filter mempool transactions | -| `tx_type` | i64 | 1=miner, 2=protocol, 3=transfer | -| `min_height` | i64 | Minimum block height | -| `max_height` | i64 | Maximum block height | -| `tx_hash` | string | Exact transaction hash | +| `isIncoming` | bool | Filter incoming transfers | +| `isOutgoing` | bool | Filter outgoing transfers | +| `isConfirmed` | bool | Filter confirmed (in-block) | +| `inPool` | bool | Filter mempool transactions | +| `txType` | i64 | Transaction type code (see below) | +| `minHeight` | i64 | Minimum block height | +| `maxHeight` | i64 | Maximum block height | +| `txHash` | string | Exact transaction hash | -**TransactionRow fields in response:** +**TransactionRow fields in response (camelCase):** | Field | Type | Description | |-------|------|-------------| -| `tx_hash` | string | Transaction hash (hex) | -| `block_height` | i64/null | Block height (null if in pool) | -| `block_timestamp` | i64 | Unix timestamp | -| `is_incoming` | bool | Received funds | -| `is_outgoing` | bool | Sent funds | -| `is_confirmed` | bool | In a block | -| `incoming_amount` | string | Atomic units received | -| `outgoing_amount` | string | Atomic units sent | +| `txHash` | string | Transaction hash (hex) | +| `txPubKey` | string/null | Transaction public key (hex) | +| `blockHeight` | i64/null | Block height (null if in pool) | +| `blockTimestamp` | i64/null | Unix timestamp | +| `isIncoming` | bool | Received funds | +| `isOutgoing` | bool | Sent funds | +| `isConfirmed` | bool | In a block | +| `incomingAmount` | string | Atomic units received | +| `outgoingAmount` | string | Atomic units sent | | `fee` | string | Transaction fee (atomic) | -| `asset_type` | string | e.g. "SAL" | -| `tx_type` | i64 | Transaction type code | +| `changeAmount` | string | Change returned to self (atomic) | +| `unlockTime` | string | Unlock time (0 = immediate) | +| `assetType` | string | e.g. "SAL" | +| `txType` | i64 | Transaction type code (see below) | +| `isMinerTx` | bool | Coinbase transaction | +| `isProtocolTx` | bool | Protocol yield/return transaction | +| `note` | string | User note (empty if unset) | + +**Transaction types (`txType`):** + +| Value | Meaning | +|-------|---------| +| 1 | Miner (coinbase) | +| 2 | Protocol (yield distribution, stake returns) | +| 3 | Transfer | +| 4 | Convert (SAL <-> VSD) | +| 5 | Burn | +| 6 | Stake | +| 7 | Return (stake unlock) | + +**Display logic:** + +- `isIncoming && !isOutgoing` — received funds, show `incomingAmount` +- `!isIncoming && isOutgoing` — sent funds, show `outgoingAmount - changeAmount` +- `isIncoming && isOutgoing` — self-transfer, net = `incomingAmount - outgoingAmount` ## 7. Query Outputs (UTXOs) ```c // All unspent outputs -char* outs = salvium_wallet_get_outputs(wallet, "{\"is_spent\":false}"); +char* outs = salvium_wallet_get_outputs(wallet, "{\"isSpent\":false}"); // Unspent SAL only -char* outs = salvium_wallet_get_outputs(wallet, "{\"is_spent\":false,\"asset_type\":\"SAL\"}"); +char* outs = salvium_wallet_get_outputs(wallet, "{\"isSpent\":false,\"assetType\":\"SAL\"}"); salvium_string_free(outs); ``` -## 8. Other Wallet Queries +**OutputQuery fields (all optional, camelCase):** + +| Field | Type | Description | +|-------|------|-------------| +| `isSpent` | bool | Filter spent/unspent | +| `isFrozen` | bool | Filter frozen outputs | +| `assetType` | string | e.g. "SAL" | +| `txType` | i64 | Transaction type code | +| `accountIndex` | i64 | Account major index | +| `subaddressIndex` | i64 | Subaddress minor index | +| `minAmount` | string | Minimum amount (atomic) | +| `maxAmount` | string | Maximum amount (atomic) | + +## 8. Subaddresses + +```c +// Create a new subaddress (returns JSON: {"major":0,"minor":1,"address":"..."}) +char* sub = salvium_wallet_create_subaddress(wallet, 0, "my label"); +salvium_string_free(sub); + +// List all subaddresses for account 0 +char* subs = salvium_wallet_get_subaddresses(wallet, 0); +salvium_string_free(subs); + +// Label an existing subaddress +salvium_wallet_label_subaddress(wallet, 0, 1, "new label"); +``` + +Subaddresses are automatically CARROT or CryptoNote based on the current chain hardfork. + +## 9. Other Wallet Queries ```c // Addresses @@ -240,14 +321,29 @@ uint64_t h = salvium_wallet_sync_height(wallet); int net = salvium_wallet_network(wallet); // 0=main, 1=test, 2=stage ``` -## 9. Staking +## 10. Staking ```c char* stakes = salvium_wallet_get_stakes(wallet, "locked"); // or "returned" or NULL for all salvium_string_free(stakes); ``` -## 10. Cleanup +**StakeRow fields (camelCase):** + +| Field | Type | Description | +|-------|------|-------------| +| `stakeTxHash` | string | Stake transaction hash | +| `stakeHeight` | i64/null | Block height of stake | +| `stakeTimestamp` | i64/null | Unix timestamp of stake | +| `amountStaked` | string | Amount staked (atomic) | +| `fee` | string | Transaction fee (atomic) | +| `assetType` | string | e.g. "SAL" | +| `status` | string | "locked" or "returned" | +| `returnTxHash` | string/null | Return transaction hash | +| `returnHeight` | i64/null | Block height of return | +| `returnAmount` | string | Amount returned (atomic) | + +## 11. Cleanup **Close in reverse order.** Always close handles when done. @@ -312,7 +408,7 @@ printf("Balance: %s\n", bal); salvium_string_free(bal); // Read transactions -char* txs = salvium_wallet_get_transfers(wallet, "{\"is_confirmed\":true}"); +char* txs = salvium_wallet_get_transfers(wallet, "{\"isConfirmed\":true}"); printf("Transfers: %s\n", txs); salvium_string_free(txs);