Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/skills/controller-skill/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions LLM_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <username>` 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):
Expand All @@ -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
Expand Down
46 changes: 18 additions & 28 deletions src/commands/marketplace/buy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
Expand Down Expand Up @@ -116,15 +116,15 @@ 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()));

// First, check order validity
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(
Expand All @@ -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()
Expand All @@ -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}"
)));
}

Expand All @@ -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,
Expand Down Expand Up @@ -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)),
};
Expand All @@ -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"
))
})?
};
Expand All @@ -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}"
));
}

Expand All @@ -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"
)));
}

Expand Down Expand Up @@ -309,8 +301,7 @@ fn validate_marketplace_policies(policies: &Option<PolicyStorage>) -> 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) => {
Expand All @@ -320,8 +311,7 @@ fn validate_marketplace_policies(policies: &Option<PolicyStorage>) -> Result<()>
});
if !has_execute {
missing.push(format!(
"execute on marketplace contract (0x{:x})",
MARKETPLACE_CONTRACT
"execute on marketplace contract (0x{MARKETPLACE_CONTRACT:x})"
));
}
}
Expand Down
16 changes: 7 additions & 9 deletions src/commands/marketplace/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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!(
Expand Down
11 changes: 5 additions & 6 deletions src/commands/marketplace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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),
Expand Down
68 changes: 61 additions & 7 deletions src/commands/session/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}
#[derive(Deserialize)]
struct LookupResponse {
results: Vec<LookupEntry>,
}

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
Expand Down Expand Up @@ -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();

Expand Down