From ff84b58bfe3a4a8c74f2bcc38a4d93e95d2a1c79 Mon Sep 17 00:00:00 2001 From: broody Date: Tue, 24 Feb 2026 11:05:36 -1000 Subject: [PATCH 1/2] feat: verify account via lookup API during session authorization When --account is provided, look up the username via the Cartridge API to verify it resolves to a controller address before proceeding. Display the username and address in the output and pass account as a URL param. Also fix all clippy warnings in marketplace commands. Co-Authored-By: Claude Opus 4.6 --- src/commands/marketplace/buy.rs | 46 ++++++++------------- src/commands/marketplace/info.rs | 16 ++++---- src/commands/marketplace/mod.rs | 11 +++-- src/commands/session/authorize.rs | 68 +++++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 50 deletions(-) diff --git a/src/commands/marketplace/buy.rs b/src/commands/marketplace/buy.rs index 4b58fd7..2e06ed6 100644 --- a/src/commands/marketplace/buy.rs +++ b/src/commands/marketplace/buy.rs @@ -38,7 +38,7 @@ pub async fn execute( ) -> Result<()> { // Parse addresses and IDs let collection_felt = Felt::from_hex(&collection) - .map_err(|e| CliError::InvalidInput(format!("Invalid collection address: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid collection address: {e}")))?; let (token_id_low, token_id_high) = encode_u256(&token_id)?; let (asset_id_low, asset_id_high) = match asset_id { Some(ref id) => encode_u256(id)?, @@ -116,7 +116,7 @@ pub async fn execute( } let rpc_parsed = url::Url::parse(&effective_rpc_url) - .map_err(|e| CliError::InvalidInput(format!("Invalid RPC URL: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid RPC URL: {e}")))?; let provider = JsonRpcClient::new(HttpTransport::new(rpc_parsed.clone())); @@ -124,7 +124,7 @@ pub async fn execute( formatter.info("Checking order validity..."); let validity_selector = starknet::core::utils::get_selector_from_name("get_validity") - .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {e}")))?; let validity_result = provider .call( @@ -141,7 +141,7 @@ pub async fn execute( BlockId::Tag(BlockTag::Latest), ) .await - .map_err(|e| CliError::TransactionFailed(format!("Validity check failed: {}", e)))?; + .map_err(|e| CliError::TransactionFailed(format!("Validity check failed: {e}")))?; let is_valid = validity_result .first() @@ -151,10 +151,9 @@ pub async fn execute( if !is_valid { let reason_felt = validity_result.get(1).copied().unwrap_or(Felt::ZERO); let reason = starknet::core::utils::parse_cairo_short_string(&reason_felt) - .unwrap_or_else(|_| format!("0x{:x}", reason_felt)); + .unwrap_or_else(|_| format!("0x{reason_felt:x}")); return Err(CliError::InvalidInput(format!( - "Order #{} is not valid: {}", - order_id, reason + "Order #{order_id} is not valid: {reason}" ))); } @@ -178,7 +177,7 @@ pub async fn execute( // Build execute call let execute_selector = starknet::core::utils::get_selector_from_name("execute") - .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {e}")))?; let execute_calldata = build_execute_calldata( order_id, @@ -213,11 +212,11 @@ pub async fn execute( Some(backend), ) .await - .map_err(|e| CliError::Storage(format!("Failed to create controller: {}", e)))?; + .map_err(|e| CliError::Storage(format!("Failed to create controller: {e}")))?; let chain_name = match controller.provider.chain_id().await { Ok(felt) => starknet::core::utils::parse_cairo_short_string(&felt) - .unwrap_or_else(|_| format!("0x{:x}", felt)), + .unwrap_or_else(|_| format!("0x{felt:x}")), Err(_) => starknet::core::utils::parse_cairo_short_string(&controller_metadata.chain_id) .unwrap_or_else(|_| format!("0x{:x}", controller_metadata.chain_id)), }; @@ -226,29 +225,24 @@ pub async fn execute( // Execute let result = if no_paymaster { formatter.info(&format!( - "Purchasing order #{} on {} without paymaster...", - order_id, chain_name + "Purchasing order #{order_id} on {chain_name} without paymaster..." )); let estimate = controller .estimate_invoke_fee(calls.clone()) .await - .map_err(|e| CliError::TransactionFailed(format!("Fee estimation failed: {}", e)))?; + .map_err(|e| CliError::TransactionFailed(format!("Fee estimation failed: {e}")))?; controller .execute(calls, Some(estimate), None) .await - .map_err(|e| CliError::TransactionFailed(format!("Transaction failed: {}", e)))? + .map_err(|e| CliError::TransactionFailed(format!("Transaction failed: {e}")))? } else { - formatter.info(&format!( - "Purchasing order #{} on {}...", - order_id, chain_name - )); + formatter.info(&format!("Purchasing order #{order_id} on {chain_name}...")); controller .execute_from_outside_v3(calls, None) .await .map_err(|e| { CliError::TransactionFailed(format!( - "Paymaster execution failed: {}\nUse --no-paymaster to force self-pay", - e + "Paymaster execution failed: {e}\nUse --no-paymaster to force self-pay" )) })? }; @@ -263,8 +257,7 @@ pub async fn execute( }); } else { formatter.info(&format!( - "Transaction: https://{}voyager.online/tx/{}", - voyager_subdomain, transaction_hash + "Transaction: https://{voyager_subdomain}voyager.online/tx/{transaction_hash}" )); } @@ -278,8 +271,7 @@ pub async fn execute( loop { if start.elapsed() > timeout_duration { return Err(CliError::TransactionFailed(format!( - "Transaction confirmation timeout after {} seconds", - timeout + "Transaction confirmation timeout after {timeout} seconds" ))); } @@ -309,8 +301,7 @@ fn validate_marketplace_policies(policies: &Option) -> Result<()> match policies { None => { missing.push(format!( - "execute on marketplace contract (0x{:x})", - MARKETPLACE_CONTRACT + "execute on marketplace contract (0x{MARKETPLACE_CONTRACT:x})" )); } Some(policies) => { @@ -320,8 +311,7 @@ fn validate_marketplace_policies(policies: &Option) -> Result<()> }); if !has_execute { missing.push(format!( - "execute on marketplace contract (0x{:x})", - MARKETPLACE_CONTRACT + "execute on marketplace contract (0x{MARKETPLACE_CONTRACT:x})" )); } } diff --git a/src/commands/marketplace/info.rs b/src/commands/marketplace/info.rs index 4f180e6..23a23a5 100644 --- a/src/commands/marketplace/info.rs +++ b/src/commands/marketplace/info.rs @@ -42,24 +42,23 @@ pub async fn execute( .unwrap_or_else(|| "https://api.cartridge.gg/x/starknet/sepolia".to_string()); let url = url::Url::parse(&rpc_url) - .map_err(|e| CliError::InvalidInput(format!("Invalid RPC URL: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid RPC URL: {e}")))?; let provider = JsonRpcClient::new(HttpTransport::new(url)); // Parse collection address let collection_felt = Felt::from_hex(&collection) - .map_err(|e| CliError::InvalidInput(format!("Invalid collection address: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid collection address: {e}")))?; // Parse token_id as u256 (low, high) let (token_id_low, token_id_high) = super::encode_u256(&token_id)?; formatter.info(&format!( - "Querying order #{} for collection {} token {}...", - order_id, collection, token_id + "Querying order #{order_id} for collection {collection} token {token_id}..." )); // Call get_validity on the marketplace contract let selector = starknet::core::utils::get_selector_from_name("get_validity") - .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {}", e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {e}")))?; let result = provider .call( @@ -76,13 +75,13 @@ pub async fn execute( BlockId::Tag(BlockTag::Latest), ) .await - .map_err(|e| CliError::TransactionFailed(format!("get_validity call failed: {}", e)))?; + .map_err(|e| CliError::TransactionFailed(format!("get_validity call failed: {e}")))?; // Parse result: (bool, felt252) let is_valid = result.first().map(|f| *f != Felt::ZERO).unwrap_or(false); let reason_felt = result.get(1).copied().unwrap_or(Felt::ZERO); let validity_reason = starknet::core::utils::parse_cairo_short_string(&reason_felt) - .unwrap_or_else(|_| format!("0x{:x}", reason_felt)); + .unwrap_or_else(|_| format!("0x{reason_felt:x}")); let order_info = OrderInfo { order_id, @@ -100,8 +99,7 @@ pub async fn execute( formatter.success(&InfoOutput { order: order_info }); } else if is_valid { formatter.info(&format!( - "✅ Order #{} is valid and can be purchased", - order_id + "✅ Order #{order_id} is valid and can be purchased" )); } else { formatter.warning(&format!( diff --git a/src/commands/marketplace/mod.rs b/src/commands/marketplace/mod.rs index 281242a..9c3c34e 100644 --- a/src/commands/marketplace/mod.rs +++ b/src/commands/marketplace/mod.rs @@ -23,7 +23,7 @@ pub fn encode_u256(value: &str) -> Result<(Felt, Felt)> { // Parse as hex - handle potential large values // If it fits in a Felt, the high bits are zero for most token IDs let felt = Felt::from_hex(value) - .map_err(|e| CliError::InvalidInput(format!("Invalid hex value '{}': {}", value, e)))?; + .map_err(|e| CliError::InvalidInput(format!("Invalid hex value '{value}': {e}")))?; // For token IDs that fit in 128 bits (common case), high = 0 // Extract low 128 bits from felt bytes @@ -34,9 +34,9 @@ pub fn encode_u256(value: &str) -> Result<(Felt, Felt)> { Ok((Felt::from(low), Felt::from(high))) } else { // Parse as decimal - let low: u128 = value.parse().map_err(|e| { - CliError::InvalidInput(format!("Invalid decimal value '{}': {}", value, e)) - })?; + let low: u128 = value + .parse() + .map_err(|e| CliError::InvalidInput(format!("Invalid decimal value '{value}': {e}")))?; // Decimal values that fit in u128 have high = 0 Ok((Felt::from(low), Felt::ZERO)) @@ -85,8 +85,7 @@ pub fn resolve_chain_id_to_rpc( "https://api.cartridge.gg/x/starknet/sepolia".to_string(), )), _ => Err(CliError::InvalidInput(format!( - "Unsupported chain ID '{}'. Supported chains: SN_MAIN, SN_SEPOLIA", - chain + "Unsupported chain ID '{chain}'. Supported chains: SN_MAIN, SN_SEPOLIA" ))), }, None => Ok(rpc_url), diff --git a/src/commands/session/authorize.rs b/src/commands/session/authorize.rs index e37cb1e..04a5bf6 100644 --- a/src/commands/session/authorize.rs +++ b/src/commands/session/authorize.rs @@ -101,7 +101,54 @@ pub async fn execute( } if let Some(name) = account { - formatter.info(&format!("Using account: {name}")); + // Look up the account to verify it resolves to a controller address + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| CliError::ApiError(format!("Failed to build HTTP client: {e}")))?; + + let response = client + .post("https://api.cartridge.gg/accounts/lookup") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "usernames": [name] })) + .send() + .await + .map_err(|e| CliError::ApiError(format!("Account lookup failed: {e}")))?; + + if !response.status().is_success() { + return Err(CliError::ApiError(format!( + "Account lookup failed with status: {}", + response.status() + ))); + } + + #[derive(Deserialize)] + struct LookupEntry { + addresses: Vec, + } + #[derive(Deserialize)] + struct LookupResponse { + results: Vec, + } + + let lookup: LookupResponse = response + .json() + .await + .map_err(|e| CliError::ApiError(format!("Failed to parse lookup response: {e}")))?; + + let address = lookup + .results + .first() + .and_then(|entry| entry.addresses.first()) + .ok_or_else(|| { + CliError::InvalidInput(format!( + "Account '{name}' not found. Verify the account exists on Cartridge." + )) + })?; + + formatter.info(&format!( + "Authorizing session for account {name} ({address})" + )); } // Check if there's an active unexpired session before proceeding @@ -372,12 +419,19 @@ pub async fn execute( let mut url = Url::parse(&format!("{}/session", config.session.keychain_url)) .map_err(|e| CliError::InvalidInput(format!("Invalid keychain URL: {e}")))?; - url.query_pairs_mut() - .append_pair("public_key", &public_key) - .append_pair("redirect_uri", "https://x.cartridge.gg") - .append_pair("policies", &policies_json) - .append_pair("rpc_url", effective_rpc_url) - .append_pair("mode", "cli"); // Tell keychain this is CLI mode (don't include session data in redirect) + { + let mut pairs = url.query_pairs_mut(); + pairs + .append_pair("public_key", &public_key) + .append_pair("redirect_uri", "https://x.cartridge.gg") + .append_pair("policies", &policies_json) + .append_pair("rpc_url", effective_rpc_url) + .append_pair("mode", "cli"); // Tell keychain this is CLI mode (don't include session data in redirect) + + if let Some(name) = account { + pairs.append_pair("account", name); + } + } let authorization_url = url.to_string(); From 2ac2085c3551db9a3832ec7c3307f50e316af092 Mon Sep 17 00:00:00 2001 From: broody Date: Tue, 24 Feb 2026 11:07:26 -1000 Subject: [PATCH 2/2] docs: document --account flag for session auth Co-Authored-By: Claude Opus 4.6 --- .claude/skills/controller-skill/skill.md | 9 +++++++++ LLM_USAGE.md | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/.claude/skills/controller-skill/skill.md b/.claude/skills/controller-skill/skill.md index 7b69352..bf8a12d 100644 --- a/.claude/skills/controller-skill/skill.md +++ b/.claude/skills/controller-skill/skill.md @@ -51,6 +51,10 @@ Generate a keypair and authorize a new session in a single step. "rpc_url": { "type": "string", "description": "RPC URL (overrides config, conflicts with chain_id)" + }, + "account": { + "type": "string", + "description": "Cartridge username to authorize the session for. Verifies the account exists and displays the resolved address. Also isolates session storage per account." } } } @@ -63,6 +67,11 @@ Generate a keypair and authorize a new session in a single step. controller session auth --preset loot-survivor --chain-id SN_MAIN --json ``` +**Example (preset with account):** +```bash +controller session auth --preset loot-survivor --chain-id SN_MAIN --account shinobi --json +``` + **Example (policy file):** ```bash controller session auth --file policy.json --json diff --git a/LLM_USAGE.md b/LLM_USAGE.md index 8b8fd74..8203e82 100644 --- a/LLM_USAGE.md +++ b/LLM_USAGE.md @@ -91,6 +91,8 @@ Active session output: The `session auth` command combines keypair generation and session registration in a single step. +**`--account` flag:** Use `--account ` to authorize a session for a specific Cartridge account. The CLI will verify the username exists and resolve it to a controller address before proceeding. This also isolates session storage per account, enabling multiple concurrent sessions. + #### Option A: Use a Preset (Recommended) For popular games/apps, use a preset from [cartridge-gg/presets](https://github.com/cartridge-gg/presets/tree/main/configs): @@ -102,6 +104,15 @@ controller session auth \ --json ``` +**With a specific account:** +```bash +controller session auth \ + --preset loot-survivor \ + --chain-id SN_MAIN \ + --account shinobi \ + --json +``` + Available presets: loot-survivor, influence, realms, pistols, dope-wars, and more. #### Option B: Use a Local Policy File