diff --git a/.claude/skills/controller-skill/skill.md b/.claude/skills/controller-skill/skill.md index 3fda805..6deb9c8 100644 --- a/.claude/skills/controller-skill/skill.md +++ b/.claude/skills/controller-skill/skill.md @@ -550,6 +550,108 @@ controller config set token.MYTOKEN 0x123... --- +### controller_marketplace_info + +Query marketplace order validity before purchasing. + +**When to use:** To check if a marketplace order is valid and can be purchased. + +**Input Schema:** +```json +{ + "type": "object", + "properties": { + "order_id": { + "type": "integer", + "description": "The marketplace order ID" + }, + "collection": { + "type": "string", + "description": "NFT collection contract address" + }, + "token_id": { + "type": "string", + "description": "Token ID in the collection" + }, + "chain_id": { + "type": "string", + "description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')" + } + }, + "required": ["order_id", "collection", "token_id"] +} +``` + +**Example:** +```bash +controller marketplace info --order-id 42 --collection 0x123...abc --token-id 1 --chain-id SN_MAIN --json +``` + +--- + +### controller_marketplace_buy + +Purchase an NFT from a marketplace listing. + +**When to use:** To buy an NFT from an active marketplace order. + +**Input Schema:** +```json +{ + "type": "object", + "properties": { + "order_id": { + "type": "integer", + "description": "The marketplace order ID to purchase" + }, + "collection": { + "type": "string", + "description": "NFT collection contract address" + }, + "token_id": { + "type": "string", + "description": "Token ID in the collection" + }, + "asset_id": { + "type": "string", + "description": "Asset ID for ERC1155 tokens (defaults to 0)" + }, + "quantity": { + "type": "integer", + "description": "Quantity to purchase (defaults to 1)" + }, + "no_royalties": { + "type": "boolean", + "description": "Skip paying creator royalties" + }, + "chain_id": { + "type": "string", + "description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')" + }, + "wait": { + "type": "boolean", + "description": "Wait for transaction confirmation" + }, + "no_paymaster": { + "type": "boolean", + "description": "Pay gas yourself instead of using paymaster" + } + }, + "required": ["order_id", "collection", "token_id"] +} +``` + +**Example:** +```bash +controller marketplace buy --order-id 42 --collection 0x123...abc --token-id 1 --chain-id SN_MAIN --wait --json +``` + +**Required Session Policies:** +- `execute` on marketplace contract (`0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652`) +- `approve` on payment token (e.g., STRK) + +--- + ## Calldata Formats Calldata values support multiple formats: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4c2d8f2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,112 @@ +# AGENTS.md — Contributing Guidelines + +Guidelines for AI agents and human contributors working on this codebase. + +## Before You Code + +1. **Read the docs** — `README.md`, `LLM_USAGE.md`, and relevant source files +2. **Understand the architecture** — This is a thin CLI wrapper around [`account_sdk`](https://github.com/cartridge-gg/controller-rs) +3. **Check existing patterns** — Follow the style of existing commands in `src/commands/` + +## Code Style + +### Rust Standards + +- **Format**: Run `cargo fmt` before committing +- **Lint**: Run `cargo clippy` and fix warnings +- **Build**: Ensure `cargo build` succeeds +- **Test**: Run `cargo test` if tests exist + +### CLI Conventions + +- All commands support `--json` for machine-readable output +- Use the `JsonOutput` struct for consistent response format +- Include `error_code`, `message`, and `recovery_hint` in error responses +- Add new commands to `src/commands/mod.rs` + +### Documentation + +- Update `LLM_USAGE.md` when adding/modifying commands +- Update `SKILL.md` when adding new tools for agents +- Include examples in doc comments + +## PR Guidelines + +### Before Opening a PR + +```bash +# Required checks +cargo fmt +cargo clippy +cargo build +cargo test # if tests exist +``` + +### PR Title Format + +``` +type(scope): description + +# Examples: +feat(marketplace): add buy and info commands +fix(session): handle expired token refresh +docs(readme): add calldata format examples +``` + +Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` + +### PR Description + +Include: +- **What** — Brief description of changes +- **Why** — Motivation or issue being solved +- **How** — Technical approach if non-obvious +- **Testing** — How you verified the changes work + +### Commit Messages + +- Use conventional commits format +- Keep subject line under 72 characters +- Reference issues with `#123` if applicable + +## Adding New Commands + +1. Create a new file in `src/commands/` (or a subdirectory for command groups) +2. Implement the command struct with clap derive macros +3. Add to the command enum in `src/commands/mod.rs` +4. Add the subcommand variant in `src/main.rs` +5. Update `LLM_USAGE.md` with usage examples +6. Update `.claude/skills/controller-skill/skill.md` if it's agent-relevant + +### Command Structure + +```rust +use clap::Parser; +use crate::utils::output::JsonOutput; + +#[derive(Parser, Debug)] +pub struct MyCommand { + /// Description of the argument + #[arg(long)] + pub some_arg: String, +} + +impl MyCommand { + pub async fn run(&self, config: &Config) -> Result<()> { + // Implementation + } +} +``` + +## Security Considerations + +- Never log or output private keys +- Session credentials stay in `~/.config/controller-cli/` +- Validate all user inputs +- Use `--json` output for programmatic access (no parsing stdout text) + +## Getting Help + +- [Controller Docs](https://docs.cartridge.gg/controller) +- [Starknet Docs](https://docs.starknet.io/) +- [account_sdk source](https://github.com/cartridge-gg/controller-rs) diff --git a/LLM_USAGE.md b/LLM_USAGE.md index 3ce34c3..10a3408 100644 --- a/LLM_USAGE.md +++ b/LLM_USAGE.md @@ -368,6 +368,67 @@ Valid keys: `rpc-url`, `keychain-url`, `api-url`, `storage-path`, `json-output`, --- +### 12. Marketplace Commands + +Buy NFTs from the Arcade marketplace. + +#### Query Order Info + +Check if an order is valid before purchasing: + +```bash +controller marketplace info \ + --order-id 42 \ + --collection 0x123...abc \ + --token-id 1 \ + --chain-id SN_MAIN \ + --json +``` + +Output: +```json +{ + "order": { + "order_id": 42, + "collection": "0x123...abc", + "token_id": "1", + "is_valid": true, + "validity_reason": "Order is valid" + } +} +``` + +#### Buy from Listing + +Purchase an NFT from an active marketplace listing: + +```bash +controller marketplace buy \ + --order-id 42 \ + --collection 0x123...abc \ + --token-id 1 \ + --chain-id SN_MAIN \ + --wait \ + --json +``` + +Options: +- `--order-id` (required): The marketplace order ID +- `--collection` (required): NFT collection contract address +- `--token-id` (required): Token ID in the collection +- `--asset-id`: Specific asset ID for ERC1155 (defaults to 0) +- `--quantity`: Number to purchase (defaults to 1) +- `--no-royalties`: Skip paying creator royalties +- `--wait`: Wait for transaction confirmation +- `--no-paymaster`: Pay gas yourself instead of using paymaster + +**Required Session Policies:** +Your session must include policies for: +- `execute` on the marketplace contract (`0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652`) +- `approve` on the payment token (e.g., STRK) + +--- + ## Calldata Formats Calldata values support multiple formats: diff --git a/SKILL.md b/SKILL.md index fdf6a70..2fa7b61 100644 --- a/SKILL.md +++ b/SKILL.md @@ -106,6 +106,37 @@ controller config list --json # List all config values Valid keys: `rpc-url`, `keychain-url`, `api-url`, `storage-path`, `json-output`, `colors`, `callback-timeout`, `token.`. +### Marketplace + +Buy NFTs from the Arcade marketplace. + +```bash +# Check if an order is valid +controller marketplace info \ + --order-id 42 \ + --collection 0x123...abc \ + --token-id 1 \ + --chain-id SN_MAIN --json + +# Purchase from a listing +controller marketplace buy \ + --order-id 42 \ + --collection 0x123...abc \ + --token-id 1 \ + --chain-id SN_MAIN \ + --wait --json +``` + +Options: +- `--order-id`: Marketplace order ID +- `--collection`: NFT collection address +- `--token-id`: Token ID to purchase +- `--asset-id`: Asset ID for ERC1155 (default: 0) +- `--quantity`: Amount to buy (default: 1) +- `--no-royalties`: Skip creator royalties + +Required session policies: `execute` on marketplace contract, `approve` on payment token. + ## Calldata Format - Values are comma-separated diff --git a/docs/PRD-marketplace-commands.md b/docs/PRD-marketplace-commands.md new file mode 100644 index 0000000..695fd05 --- /dev/null +++ b/docs/PRD-marketplace-commands.md @@ -0,0 +1,385 @@ +# PRD: Marketplace Commands for Controller CLI + +## Overview + +Extend the Controller CLI to support Arcade marketplace operations, enabling users to purchase NFTs from listings without ambiguity in calldata formatting. + +## Problem Statement + +Currently, purchasing from the Arcade marketplace requires: +1. Manually constructing complex calldata with proper type encoding (u256, ContractAddress) +2. Understanding the marketplace contract interface and parameter ordering +3. Building multi-call transactions (approve + execute) +4. Knowing the correct contract addresses per network + +This creates friction for CLI users and increases the risk of transaction failures due to malformed calldata. + +## Goals + +1. **Unambiguous purchases**: `controller marketplace buy` should "just work" +2. **Consistent UX**: Mirror the patterns established by `starterpack` commands +3. **Safety**: Validate session policies before attempting transactions +4. **Discoverability**: Query listings and orders before purchasing + +## Non-Goals + +- Full marketplace management (listing, offers, intents) - future PRs +- Collection browsing/search - use web UI +- Admin operations (pause, fees, roles) + +## Marketplace Contract Interface + +From `arcade/contracts/src/systems/marketplace.cairo`: + +```cairo +fn execute( + ref self: ContractState, + order_id: u32, // Order identifier + collection: ContractAddress, // NFT collection address + token_id: u256, // Token ID in collection + asset_id: u256, // Specific asset (for ERC1155) + quantity: u128, // Amount to purchase + royalties: bool, // Pay creator royalties + client_fee: u32, // Client app fee (basis points) + client_receiver: ContractAddress, // Client fee recipient +); +``` + +## Contract Addresses + +| Network | Marketplace Contract | +|---------|---------------------| +| Mainnet | `0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652` | +| Sepolia | `0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652` | + +## Proposed Commands + +### `controller marketplace buy` + +Purchase an NFT from an existing listing. + +```bash +controller marketplace buy \ + --order-id 42 \ + --collection 0x123...abc \ + --token-id 1 \ + [--asset-id 0] \ + [--quantity 1] \ + [--no-royalties] \ + [--chain-id SN_MAIN|SN_SEPOLIA] \ + [--rpc-url URL] \ + [--wait] \ + [--no-paymaster] \ + [--json] +``` + +**Behavior:** +1. Query order details from Torii/marketplace to get price and currency +2. Validate order is still valid (not expired, not filled) +3. Build approve call for payment token +4. Build execute call with properly formatted calldata +5. Validate session has required policies +6. Execute multicall via Controller + +**Required Session Policies:** +- `approve` on payment token (ERC20) +- `execute` on marketplace contract + +### `controller marketplace info` + +Query order/listing details before purchasing. + +```bash +controller marketplace info \ + --order-id 42 \ + --collection 0x123...abc \ + --token-id 1 \ + [--chain-id SN_MAIN|SN_SEPOLIA] \ + [--json] +``` + +**Output:** +```json +{ + "order_id": 42, + "collection": "0x123...abc", + "token_id": "1", + "price": "1.5", + "currency": "STRK", + "currency_address": "0x04718f5a...", + "seller": "0xabc...def", + "status": "active", + "expires_at": "2026-03-01T00:00:00Z", + "royalties_enabled": true +} +``` + +### `controller marketplace orders` (Future) + +List active orders for a collection. + +```bash +controller marketplace orders \ + --collection 0x123...abc \ + [--token-id 1] \ + [--status active|filled|cancelled] \ + [--limit 20] \ + [--json] +``` + +## Calldata Formatting + +### u256 Encoding + +u256 values are encoded as two felt252 (low, high): +```rust +fn encode_u256(value: U256) -> Vec { + vec![ + Felt::from(value.low), // u128 low bits + Felt::from(value.high), // u128 high bits + ] +} +``` + +### Execute Calldata + +```rust +let calldata = vec![ + Felt::from(order_id), // u32 -> felt + collection, // ContractAddress + token_id_low, // u256 low + token_id_high, // u256 high + asset_id_low, // u256 low (usually 0) + asset_id_high, // u256 high (usually 0) + Felt::from(quantity), // u128 -> felt + Felt::from(royalties as u8), // bool -> felt (0 or 1) + Felt::from(client_fee), // u32 -> felt (0 for no client fee) + Felt::ZERO, // client_receiver (zero address) +]; +``` + +## TDD Test Specifications + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Test: u256 encoding for token IDs + #[test] + fn test_encode_u256_small_value() { + let token_id = U256::from(42u64); + let encoded = encode_u256(token_id); + assert_eq!(encoded.len(), 2); + assert_eq!(encoded[0], Felt::from(42u64)); // low + assert_eq!(encoded[1], Felt::ZERO); // high + } + + #[test] + fn test_encode_u256_large_value() { + // Value larger than u128::MAX + let token_id = U256::from_str("0x1ffffffffffffffffffffffffffffffff").unwrap(); + let encoded = encode_u256(token_id); + assert_eq!(encoded.len(), 2); + assert_eq!(encoded[0], Felt::from(u128::MAX)); // low saturated + assert_eq!(encoded[1], Felt::from(1u64)); // high = 1 + } + + // Test: Execute calldata building + #[test] + fn test_build_execute_calldata() { + let order_id = 42u32; + let collection = Felt::from_hex("0x123").unwrap(); + let token_id = U256::from(1u64); + let quantity = 1u128; + let royalties = true; + + let calldata = build_execute_calldata( + order_id, + collection, + token_id, + U256::ZERO, // asset_id + quantity, + royalties, + 0, // client_fee + Felt::ZERO, // client_receiver + ); + + assert_eq!(calldata.len(), 10); + assert_eq!(calldata[0], Felt::from(42u32)); // order_id + assert_eq!(calldata[1], collection); // collection + assert_eq!(calldata[2], Felt::from(1u64)); // token_id low + assert_eq!(calldata[3], Felt::ZERO); // token_id high + assert_eq!(calldata[4], Felt::ZERO); // asset_id low + assert_eq!(calldata[5], Felt::ZERO); // asset_id high + assert_eq!(calldata[6], Felt::from(1u128)); // quantity + assert_eq!(calldata[7], Felt::from(1u8)); // royalties = true + assert_eq!(calldata[8], Felt::ZERO); // client_fee + assert_eq!(calldata[9], Felt::ZERO); // client_receiver + } + + // Test: Policy validation + #[test] + fn test_validate_policies_missing_approve() { + let policies = PolicyStorage { contracts: vec![] }; + let payment_token = Felt::from_hex("0x04718f5a...").unwrap(); + + let result = validate_marketplace_policies(&Some(policies), payment_token); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("approve")); + } + + #[test] + fn test_validate_policies_missing_execute() { + let policies = PolicyStorage { + contracts: vec![( + "0x04718f5a...".to_string(), + ContractPolicy { methods: vec![MethodPolicy { entrypoint: "approve".to_string() }] }, + )], + }; + let payment_token = Felt::from_hex("0x04718f5a...").unwrap(); + + let result = validate_marketplace_policies(&Some(policies), payment_token); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("execute")); + } + + #[test] + fn test_validate_policies_complete() { + let policies = PolicyStorage { + contracts: vec![ + ( + "0x04718f5a...".to_string(), + ContractPolicy { methods: vec![MethodPolicy { entrypoint: "approve".to_string() }] }, + ), + ( + MARKETPLACE_CONTRACT.to_string(), + ContractPolicy { methods: vec![MethodPolicy { entrypoint: "execute".to_string() }] }, + ), + ], + }; + let payment_token = Felt::from_hex("0x04718f5a...").unwrap(); + + let result = validate_marketplace_policies(&Some(policies), payment_token); + assert!(result.is_ok()); + } +} +``` + +### Integration Tests + +```rust +#[tokio::test] +async fn test_marketplace_info_valid_order() { + // Setup: Create a test listing on Sepolia + // Query: controller marketplace info --order-id 1 --collection 0x... --token-id 1 + // Assert: Returns valid order details +} + +#[tokio::test] +async fn test_marketplace_info_invalid_order() { + // Query non-existent order + // Assert: Returns appropriate error +} + +#[tokio::test] +async fn test_marketplace_buy_insufficient_balance() { + // Setup: Session with insufficient payment token balance + // Execute: marketplace buy + // Assert: Fails with balance error before transaction +} + +#[tokio::test] +async fn test_marketplace_buy_expired_order() { + // Setup: Order that has expired + // Execute: marketplace buy + // Assert: Fails with order expired error +} +``` + +## File Structure + +``` +src/commands/ +├── marketplace/ +│ ├── mod.rs # Module exports, shared utilities +│ ├── buy.rs # Purchase command implementation +│ ├── info.rs # Order info query +│ └── types.rs # Shared types (OrderInfo, etc.) +├── mod.rs # Add marketplace module +``` + +## Implementation Plan + +### Phase 1: Core Infrastructure +1. Add `marketplace` module scaffold +2. Implement u256 encoding utilities +3. Add marketplace contract addresses to constants + +### Phase 2: Info Command +1. Implement order query via Torii GraphQL +2. Parse and display order details +3. Add validity checking + +### Phase 3: Buy Command +1. Implement quote/price fetching +2. Build approve + execute multicall +3. Add policy validation +4. Execute transaction +5. Handle wait/receipt + +### Phase 4: Polish +1. Add comprehensive error messages +2. Update CLI help text +3. Write documentation +4. Add to LLM_USAGE.md + +## Success Metrics + +1. **Zero calldata ambiguity**: Users never need to manually encode u256/addresses +2. **< 3 commands to purchase**: Info → Buy → Done +3. **Clear error messages**: Policy issues, balance problems, expired orders + +## Security Considerations + +1. **Policy validation**: Refuse to execute without proper session policies +2. **Order validation**: Check order validity before building transaction +3. **Slippage protection**: Future - add max price parameter + +## Open Questions + +1. Should we query Torii or the contract directly for order info? + - Recommendation: Torii for speed, contract `get_validity` for confirmation +2. Client fee handling - should CLI pass 0 or allow configuration? + - Recommendation: Default to 0, add optional `--client-fee` flag later + +## Appendix: Arcade Marketplace GraphQL + +```graphql +query GetOrder($orderId: Int!, $collection: String!, $tokenId: String!) { + arcadeMarketplaceOrderModels( + where: { + order_id: { eq: $orderId } + collection: { eq: $collection } + token_id: { eq: $tokenId } + } + ) { + edges { + node { + order_id + offerer + collection + token_id + price + currency + quantity + expiration + status { value } + category { value } + } + } + } +} +``` diff --git a/src/commands/marketplace/buy.rs b/src/commands/marketplace/buy.rs new file mode 100644 index 0000000..4b58fd7 --- /dev/null +++ b/src/commands/marketplace/buy.rs @@ -0,0 +1,339 @@ +use crate::commands::session::authorize::PolicyStorage; +use crate::config::Config; +use crate::error::{CliError, Result}; +use crate::output::OutputFormatter; +use account_sdk::{ + controller::Controller, + signers::{Owner, Signer}, + storage::{filestorage::FileSystemBackend, StorageBackend, StorageValue}, +}; +use serde::Serialize; +use starknet::core::types::{BlockId, BlockTag, Call, Felt, FunctionCall}; +use starknet::providers::{jsonrpc::HttpTransport, JsonRpcClient, Provider}; + +use super::{build_execute_calldata, encode_u256, resolve_chain_id_to_rpc, MARKETPLACE_CONTRACT}; + +#[derive(Serialize)] +struct BuyOutput { + transaction_hash: String, + message: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn execute( + config: &Config, + formatter: &dyn OutputFormatter, + order_id: u32, + collection: String, + token_id: String, + asset_id: Option, + quantity: u128, + no_royalties: bool, + chain_id: Option, + rpc_url: Option, + wait: bool, + timeout: u64, + no_paymaster: bool, + account: Option<&str>, +) -> Result<()> { + // Parse addresses and IDs + let collection_felt = Felt::from_hex(&collection) + .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)?, + None => (Felt::ZERO, Felt::ZERO), + }; + + // Resolve RPC URL + let rpc_url = resolve_chain_id_to_rpc(chain_id, rpc_url.clone())?; + + // Load controller metadata + let storage_path = config.resolve_storage_path(account); + let backend = FileSystemBackend::new(storage_path); + + let controller_metadata = backend + .controller() + .map_err(|e| CliError::Storage(e.to_string()))? + .ok_or_else(|| { + CliError::InvalidSessionData( + "No controller metadata found. Run 'controller session auth' to create a session." + .to_string(), + ) + })?; + + let session_key = format!( + "@cartridge/session/0x{:x}/0x{:x}", + controller_metadata.address, controller_metadata.chain_id + ); + + let session_metadata = backend + .session(&session_key) + .map_err(|e| CliError::Storage(e.to_string()))? + .ok_or(CliError::NoSession)?; + + if session_metadata.session.is_expired() { + let expires_at = + chrono::DateTime::from_timestamp(session_metadata.session.inner.expires_at as i64, 0) + .unwrap_or_else(chrono::Utc::now); + return Err(CliError::SessionExpired( + expires_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + )); + } + + let credentials = session_metadata + .credentials + .ok_or_else(|| CliError::InvalidSessionData("No credentials found".to_string()))?; + + let signing_key = starknet::signers::SigningKey::from_secret_scalar(credentials.private_key); + let owner = Owner::Signer(Signer::Starknet(signing_key)); + + // Resolve effective RPC URL + let effective_rpc_url = rpc_url + .clone() + .or_else(|| { + if config.session.rpc_url_explicitly_set { + Some(config.session.rpc_url.clone()) + } else { + None + } + }) + .or_else(|| { + backend.get("session_rpc_url").ok().and_then(|v| match v { + Some(StorageValue::String(url)) => Some(url), + _ => None, + }) + }) + .unwrap_or_else(|| config.session.rpc_url.clone()); + + // Validate Cartridge RPC endpoint + if let Some(ref url) = rpc_url { + if !url.starts_with("https://api.cartridge.gg") { + return Err(CliError::InvalidInput( + "Only Cartridge RPC endpoints are supported. Use: https://api.cartridge.gg/x/starknet/mainnet or https://api.cartridge.gg/x/starknet/sepolia".to_string() + )); + } + } + + let rpc_parsed = url::Url::parse(&effective_rpc_url) + .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)))?; + + let validity_result = provider + .call( + FunctionCall { + contract_address: MARKETPLACE_CONTRACT, + entry_point_selector: validity_selector, + calldata: vec![ + Felt::from(order_id), + collection_felt, + token_id_low, + token_id_high, + ], + }, + BlockId::Tag(BlockTag::Latest), + ) + .await + .map_err(|e| CliError::TransactionFailed(format!("Validity check failed: {}", e)))?; + + let is_valid = validity_result + .first() + .map(|f| *f != Felt::ZERO) + .unwrap_or(false); + + 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)); + return Err(CliError::InvalidInput(format!( + "Order #{} is not valid: {}", + order_id, reason + ))); + } + + formatter.info("Order is valid ✓"); + + // TODO: Query order details from Torii to get price and payment token + // For now, we'll need the user to provide payment token via session policies + // This is a simplified implementation - production would query Torii + + // Check session policies + let stored_policies: Option = backend + .get("session_policies") + .ok() + .flatten() + .and_then(|v| match v { + StorageValue::String(json) => serde_json::from_str(&json).ok(), + _ => None, + }); + + validate_marketplace_policies(&stored_policies)?; + + // Build execute call + let execute_selector = starknet::core::utils::get_selector_from_name("execute") + .map_err(|e| CliError::InvalidInput(format!("Invalid entrypoint: {}", e)))?; + + let execute_calldata = build_execute_calldata( + order_id, + collection_felt, + token_id_low, + token_id_high, + asset_id_low, + asset_id_high, + quantity, + !no_royalties, + 0, // client_fee = 0 + Felt::ZERO, // client_receiver = zero address + ); + + let calls = vec![Call { + to: MARKETPLACE_CONTRACT, + selector: execute_selector, + calldata: execute_calldata, + }]; + + // Note: In a full implementation, we would also prepend an approve call + // for the payment token. This requires querying the order to get the + // price and currency first. + + // Create controller + let mut controller = Controller::new( + controller_metadata.username.clone(), + controller_metadata.class_hash, + rpc_parsed, + owner, + controller_metadata.address, + Some(backend), + ) + .await + .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)), + Err(_) => starknet::core::utils::parse_cairo_short_string(&controller_metadata.chain_id) + .unwrap_or_else(|_| format!("0x{:x}", controller_metadata.chain_id)), + }; + let is_mainnet = chain_name == "SN_MAIN"; + + // Execute + let result = if no_paymaster { + formatter.info(&format!( + "Purchasing order #{} on {} without paymaster...", + order_id, chain_name + )); + let estimate = controller + .estimate_invoke_fee(calls.clone()) + .await + .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)))? + } else { + formatter.info(&format!( + "Purchasing order #{} on {}...", + order_id, 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 + )) + })? + }; + + let transaction_hash = format!("0x{:x}", result.transaction_hash); + let voyager_subdomain = if is_mainnet { "" } else { "sepolia." }; + + if config.cli.json_output { + formatter.success(&BuyOutput { + transaction_hash: transaction_hash.clone(), + message: "Marketplace purchase executed successfully".to_string(), + }); + } else { + formatter.info(&format!( + "Transaction: https://{}voyager.online/tx/{}", + voyager_subdomain, transaction_hash + )); + } + + // Wait for confirmation if requested + if wait { + formatter.info("Waiting for transaction confirmation..."); + + let start = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_secs(timeout); + + loop { + if start.elapsed() > timeout_duration { + return Err(CliError::TransactionFailed(format!( + "Transaction confirmation timeout after {} seconds", + timeout + ))); + } + + match controller + .provider + .get_transaction_receipt(result.transaction_hash) + .await + { + Ok(_) => { + formatter.info("Transaction confirmed!"); + break; + } + Err(_) => { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + } + } + } + + Ok(()) +} + +/// Validate that the session policies include `execute` on the marketplace contract +fn validate_marketplace_policies(policies: &Option) -> Result<()> { + let mut missing = Vec::new(); + + match policies { + None => { + missing.push(format!( + "execute on marketplace contract (0x{:x})", + MARKETPLACE_CONTRACT + )); + } + Some(policies) => { + let has_execute = policies.contracts.iter().any(|(addr, policy)| { + Felt::from_hex(addr).ok() == Some(MARKETPLACE_CONTRACT) + && policy.methods.iter().any(|m| m.entrypoint == "execute") + }); + if !has_execute { + missing.push(format!( + "execute on marketplace contract (0x{:x})", + MARKETPLACE_CONTRACT + )); + } + } + } + + if !missing.is_empty() { + return Err(CliError::InvalidInput(format!( + "Current session is missing required policies for marketplace purchase: {}. \ + Register a new session with policies that include these entrypoints.", + missing.join(", ") + ))); + } + + Ok(()) +} diff --git a/src/commands/marketplace/info.rs b/src/commands/marketplace/info.rs new file mode 100644 index 0000000..4f180e6 --- /dev/null +++ b/src/commands/marketplace/info.rs @@ -0,0 +1,114 @@ +use crate::config::Config; +use crate::error::{CliError, Result}; +use crate::output::OutputFormatter; +use serde::Serialize; +use starknet::core::types::{BlockId, BlockTag, Felt, FunctionCall}; +use starknet::providers::{jsonrpc::HttpTransport, JsonRpcClient, Provider}; + +use super::{resolve_chain_id_to_rpc, MARKETPLACE_CONTRACT}; + +#[derive(Serialize)] +pub struct OrderInfo { + pub order_id: u32, + pub collection: String, + pub token_id: String, + pub is_valid: bool, + pub validity_reason: String, +} + +#[derive(Serialize)] +struct InfoOutput { + order: OrderInfo, +} + +pub async fn execute( + config: &Config, + formatter: &dyn OutputFormatter, + order_id: u32, + collection: String, + token_id: String, + chain_id: Option, + rpc_url: Option, +) -> Result<()> { + // Resolve RPC URL + let rpc_url = resolve_chain_id_to_rpc(chain_id.clone(), rpc_url)? + .or_else(|| { + if !config.session.rpc_url.is_empty() { + Some(config.session.rpc_url.clone()) + } else { + None + } + }) + .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)))?; + 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)))?; + + // 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 + )); + + // 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)))?; + + let result = provider + .call( + FunctionCall { + contract_address: MARKETPLACE_CONTRACT, + entry_point_selector: selector, + calldata: vec![ + Felt::from(order_id), + collection_felt, + token_id_low, + token_id_high, + ], + }, + BlockId::Tag(BlockTag::Latest), + ) + .await + .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)); + + let order_info = OrderInfo { + order_id, + collection, + token_id, + is_valid, + validity_reason: if is_valid { + "Order is valid".to_string() + } else { + validity_reason + }, + }; + + if config.cli.json_output { + formatter.success(&InfoOutput { order: order_info }); + } else if is_valid { + formatter.info(&format!( + "✅ Order #{} is valid and can be purchased", + order_id + )); + } else { + formatter.warning(&format!( + "❌ Order #{} is not valid: {}", + order_id, order_info.validity_reason + )); + } + + Ok(()) +} diff --git a/src/commands/marketplace/mod.rs b/src/commands/marketplace/mod.rs new file mode 100644 index 0000000..281242a --- /dev/null +++ b/src/commands/marketplace/mod.rs @@ -0,0 +1,171 @@ +pub mod buy; +pub mod info; + +use crate::error::{CliError, Result}; +use starknet::core::types::Felt; + +/// Marketplace contract address (same on mainnet and sepolia) +pub const MARKETPLACE_CONTRACT: Felt = + Felt::from_hex_unchecked("0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652"); + +/// STRK token address (for reference in future features) +#[allow(dead_code)] +pub const STRK_TOKEN: Felt = + Felt::from_hex_unchecked("0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"); + +/// Encode a u256 value as two felt252 values (low, high) +/// Supports both decimal and hex (0x) input +pub fn encode_u256(value: &str) -> Result<(Felt, Felt)> { + // For simplicity, we'll handle values that fit in u128 (most token IDs) + // and return high=0 for those cases. For larger values, use hex parsing. + + if value.starts_with("0x") || value.starts_with("0X") { + // 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)))?; + + // For token IDs that fit in 128 bits (common case), high = 0 + // Extract low 128 bits from felt bytes + let bytes = felt.to_bytes_be(); + let low = u128::from_be_bytes(bytes[16..32].try_into().unwrap()); + let high = u128::from_be_bytes(bytes[0..16].try_into().unwrap()); + + 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)) + })?; + + // Decimal values that fit in u128 have high = 0 + Ok((Felt::from(low), Felt::ZERO)) + } +} + +/// Build the calldata for marketplace execute +#[allow(clippy::too_many_arguments)] +pub fn build_execute_calldata( + order_id: u32, + collection: Felt, + token_id_low: Felt, + token_id_high: Felt, + asset_id_low: Felt, + asset_id_high: Felt, + quantity: u128, + royalties: bool, + client_fee: u32, + client_receiver: Felt, +) -> Vec { + vec![ + Felt::from(order_id), + collection, + token_id_low, + token_id_high, + asset_id_low, + asset_id_high, + Felt::from(quantity), + Felt::from(royalties as u8), + Felt::from(client_fee), + client_receiver, + ] +} + +/// Resolve chain_id to an RPC URL, or pass through rpc_url as-is +pub fn resolve_chain_id_to_rpc( + chain_id: Option, + rpc_url: Option, +) -> Result> { + match chain_id { + Some(chain) => match chain.as_str() { + "SN_MAIN" => Ok(Some( + "https://api.cartridge.gg/x/starknet/mainnet".to_string(), + )), + "SN_SEPOLIA" => Ok(Some( + "https://api.cartridge.gg/x/starknet/sepolia".to_string(), + )), + _ => Err(CliError::InvalidInput(format!( + "Unsupported chain ID '{}'. Supported chains: SN_MAIN, SN_SEPOLIA", + chain + ))), + }, + None => Ok(rpc_url), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_u256_small_value() { + let (low, high) = encode_u256("42").unwrap(); + assert_eq!(low, Felt::from(42u64)); + assert_eq!(high, Felt::ZERO); + } + + #[test] + fn test_encode_u256_hex_value() { + let (low, high) = encode_u256("0x2a").unwrap(); + assert_eq!(low, Felt::from(42u64)); + assert_eq!(high, Felt::ZERO); + } + + #[test] + fn test_encode_u256_large_hex_value() { + // Large hex value with both low and high bits + let (low, high) = encode_u256("0x100000000000000000000000000000001").unwrap(); + assert_eq!(low, Felt::from(1u64)); + assert_eq!(high, Felt::from(1u64)); + } + + #[test] + fn test_build_execute_calldata() { + let calldata = build_execute_calldata( + 42, // order_id + Felt::from(0x123u64), // collection + Felt::from(1u64), // token_id_low + Felt::ZERO, // token_id_high + Felt::ZERO, // asset_id_low + Felt::ZERO, // asset_id_high + 1, // quantity + true, // royalties + 0, // client_fee + Felt::ZERO, // client_receiver + ); + + assert_eq!(calldata.len(), 10); + assert_eq!(calldata[0], Felt::from(42u32)); + assert_eq!(calldata[7], Felt::from(1u8)); // royalties = true + } + + #[test] + fn test_resolve_chain_id_mainnet() { + let result = resolve_chain_id_to_rpc(Some("SN_MAIN".to_string()), None).unwrap(); + assert_eq!( + result, + Some("https://api.cartridge.gg/x/starknet/mainnet".to_string()) + ); + } + + #[test] + fn test_resolve_chain_id_sepolia() { + let result = resolve_chain_id_to_rpc(Some("SN_SEPOLIA".to_string()), None).unwrap(); + assert_eq!( + result, + Some("https://api.cartridge.gg/x/starknet/sepolia".to_string()) + ); + } + + #[test] + fn test_resolve_chain_id_invalid() { + let result = resolve_chain_id_to_rpc(Some("INVALID".to_string()), None); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_rpc_url_passthrough() { + let result = resolve_chain_id_to_rpc(None, Some("https://custom.rpc".to_string())).unwrap(); + assert_eq!(result, Some("https://custom.rpc".to_string())); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8fef676..8ecf6f8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod clear; pub mod config_cmd; pub mod execute; pub mod lookup; +pub mod marketplace; pub mod receipt; pub mod session; pub mod starterpack; diff --git a/src/main.rs b/src/main.rs index cec885b..6cf2321 100644 --- a/src/main.rs +++ b/src/main.rs @@ -186,6 +186,12 @@ enum Commands { #[command(subcommand)] command: StarterpackCommands, }, + + /// Buy and interact with the Arcade marketplace + Marketplace { + #[command(subcommand)] + command: MarketplaceCommands, + }, } #[derive(Subcommand)] @@ -283,6 +289,79 @@ enum StarterpackCommands { }, } +#[derive(Subcommand)] +enum MarketplaceCommands { + /// Query order validity and details + Info { + /// Order ID + #[arg(long)] + order_id: u32, + + /// NFT collection address + #[arg(long)] + collection: String, + + /// Token ID in the collection + #[arg(long)] + token_id: String, + + /// Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA') - auto-selects RPC URL + #[arg(long, conflicts_with = "rpc_url")] + chain_id: Option, + + /// RPC URL to use (overrides config) + #[arg(long, conflicts_with = "chain_id")] + rpc_url: Option, + }, + + /// Purchase an NFT from a marketplace listing + Buy { + /// Order ID to purchase + #[arg(long)] + order_id: u32, + + /// NFT collection address + #[arg(long)] + collection: String, + + /// Token ID in the collection + #[arg(long)] + token_id: String, + + /// Asset ID (for ERC1155, defaults to 0) + #[arg(long)] + asset_id: Option, + + /// Quantity to purchase + #[arg(long, default_value = "1")] + quantity: u128, + + /// Skip paying royalties + #[arg(long)] + no_royalties: bool, + + /// Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA') - auto-selects RPC URL + #[arg(long, conflicts_with = "rpc_url")] + chain_id: Option, + + /// RPC URL to use (overrides config) + #[arg(long, conflicts_with = "chain_id")] + rpc_url: Option, + + /// Wait for transaction confirmation + #[arg(long)] + wait: bool, + + /// Timeout in seconds when waiting + #[arg(long, default_value = "300")] + timeout: u64, + + /// Force self-pay, don't use paymaster + #[arg(long)] + no_paymaster: bool, + }, +} + #[derive(Subcommand)] enum SessionCommands { /// Generate keypair and authorize a new session @@ -579,6 +658,57 @@ async fn main() { .await } }, + Commands::Marketplace { command } => match command { + MarketplaceCommands::Info { + order_id, + collection, + token_id, + chain_id, + rpc_url, + } => { + commands::marketplace::info::execute( + &config, + &*formatter, + order_id, + collection, + token_id, + chain_id, + rpc_url, + ) + .await + } + MarketplaceCommands::Buy { + order_id, + collection, + token_id, + asset_id, + quantity, + no_royalties, + chain_id, + rpc_url, + wait, + timeout, + no_paymaster, + } => { + commands::marketplace::buy::execute( + &config, + &*formatter, + order_id, + collection, + token_id, + asset_id, + quantity, + no_royalties, + chain_id, + rpc_url, + wait, + timeout, + no_paymaster, + account.as_deref(), + ) + .await + } + }, }; if let Err(e) = result {