
Controller Cli
- 12 installs
- 2 repo stars
- Updated February 26, 2026
- cartridge-gg/controller-cli
Execute Starknet smart contract transactions and manage Cartridge Controller sessions via CLI with human-authorized policies.
About
Manages Cartridge Controller sessions and executes Starknet smart contract transactions through a human-authorized, session-based CLI workflow. A developer uses it to run Starknet transactions, transfer tokens, or query contract state within authorized policies.
- Executes Starknet transactions via Cartridge Controller sessions
- Human-in-the-loop policy authorization with keypair-based sessions
Controller Cli by the numbers
- 12 all-time installs (skills.sh)
- Ranked #307 of 480 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cartridge-gg/controller-cli --skill controller-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 2 |
| Last updated | February 26, 2026 |
| Repository | cartridge-gg/controller-cli ↗ |
What it does
Execute Starknet smart contract transactions and manage Cartridge Controller sessions via CLI with human-authorized policies.
Files
Controller CLI Skill
Execute Starknet transactions using Cartridge Controller sessions.
Description
This skill enables LLMs to manage Cartridge Controller sessions and execute Starknet transactions through a secure human-in-the-loop workflow. The controller uses session-based authentication where humans authorize specific contracts and methods via browser, then the LLM can execute transactions autonomously within those constraints.
Prerequisites
- Controller CLI installed:
curl -fsSL https://raw.githubusercontent.com/cartridge-gg/controller-cli/main/install.sh | bash - User must authorize sessions via browser (one-time setup per session)
When to Use
Use this skill when the user wants to:
- Execute Starknet smart contract transactions
- Transfer tokens on Starknet
- Interact with gaming contracts
- Manage Starknet sessions
- Check transaction status or receipts
- Query token balances
- Look up usernames or addresses
- Query or purchase starterpacks
Tools
controller_session_auth
Generate a keypair and authorize a new session in a single step.
When to use: To set up a new session, or when the current session has expired.
Input Schema:
{
"type": "object",
"properties": {
"policy_file": {
"type": "string",
"description": "Path to JSON policy file defining allowed contracts and methods"
},
"preset": {
"type": "string",
"description": "Preset name (e.g., 'loot-survivor'). Alternative to policy_file."
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"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."
},
"expires": {
"type": "string",
"description": "Session expiration duration (e.g., '1min', '1hr', '1day', '7days', '1week', '1year'). Plurals supported for day/week/year. Defaults to '7days'.",
"default": "7days"
}
}
}Important: This command will output an authorization URL. Display this URL to the user and explain they need to open it in their browser to authorize. The command will automatically wait (up to 6 minutes) for authorization and store the session.
Example (preset):
controller session auth --preset loot-survivor --chain-id SN_MAIN --jsonExample (preset with account):
controller session auth --preset loot-survivor --chain-id SN_MAIN --account shinobi --jsonExample (custom expiration):
controller session auth --preset loot-survivor --chain-id SN_MAIN --expires 1hr --jsonExample (policy file):
controller session auth --file policy.json --jsonPolicy file format:
{
"contracts": {
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "ETH Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer ETH tokens"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve token spending"
}
]
}
}
}---
controller_session_status
Check current session status, expiration, and keypair information.
When to use: Before executing transactions to verify session is active, or to diagnose issues.
Input Schema:
{
"type": "object",
"properties": {}
}Output: Session status, expiration time, keypair info
Example:
controller session status --json---
controller_session_list
List all active sessions with pagination.
When to use: To see all sessions registered for the account, check which is current, or view expiration times.
Input Schema:
{
"type": "object",
"properties": {
"chain_id": {
"type": "string",
"description": "Chain ID to filter sessions (defaults to session chain)"
},
"limit": {
"type": "number",
"description": "Sessions per page (default: 10)",
"default": 10
},
"page": {
"type": "number",
"description": "Page number starting from 1 (default: 1)",
"default": 1
}
}
}Example:
controller session list --json
controller session list --limit 20 --page 2 --json---
controller_session_clear
Clear all stored session data and keypairs.
When to use: To reset and start fresh, or when troubleshooting session issues.
Input Schema:
{
"type": "object",
"properties": {
"yes": {
"type": "boolean",
"description": "Skip confirmation prompt",
"default": true
}
}
}Example:
controller session clear --yes---
controller_execute
Execute a Starknet transaction using the active session.
When to use: To execute any smart contract call within authorized policies.
Input Schema:
{
"type": "object",
"properties": {
"contract": {
"type": "string",
"description": "Contract address (positional, hex with 0x prefix)"
},
"entrypoint": {
"type": "string",
"description": "Function name to call (positional)"
},
"calldata": {
"type": "string",
"description": "Comma-separated calldata values (positional). Supports hex, decimal, u256:, and str: prefixes."
},
"file": {
"type": "string",
"description": "Path to JSON file with multiple calls (alternative to positional args)"
},
"wait": {
"type": "boolean",
"description": "Wait for transaction confirmation (default: false)",
"default": false
},
"timeout": {
"type": "number",
"description": "Timeout in seconds when waiting (default: 300)",
"default": 300
}
}
}Note: Either provide positional contract entrypoint calldata for a single call, OR provide --file for multiple calls.
Example (single call — positional args):
controller execute \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
transfer \
0xRECIPIENT_ADDRESS,u256:1000000000000000000 \
--jsonExample (multiple calls from file):
controller execute --file calls.json --jsonCalls file format:
{
"calls": [
{
"contractAddress": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"entrypoint": "transfer",
"calldata": ["0xRECIPIENT", "0x100", "0x0"]
}
]
}---
controller_call
Execute a read-only call to a contract (no session required).
When to use: To query contract state such as balances, allowances, or game state without submitting a transaction.
Input Schema:
{
"type": "object",
"properties": {
"contract": {
"type": "string",
"description": "Contract address (positional, hex with 0x prefix)"
},
"entrypoint": {
"type": "string",
"description": "Function name to call (positional)"
},
"calldata": {
"type": "string",
"description": "Comma-separated calldata values (positional, hex with 0x prefix)"
},
"file": {
"type": "string",
"description": "Path to JSON file with multiple calls (alternative to positional args)"
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
},
"block_id": {
"type": "string",
"description": "Block ID to query (latest, pending, block number, or block hash)"
}
}
}Note: Does not require an active session. Only needs a network via --chain-id or --rpc-url.
Example (positional args):
controller call \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
balance_of \
0xADDRESS \
--chain-id SN_SEPOLIA \
--jsonExample (from file):
controller call --file calls.json --chain-id SN_SEPOLIA --json---
controller_transaction
Get transaction status and details.
When to use: To check whether a previously submitted transaction has been confirmed, or to wait for confirmation.
Input Schema:
{
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "Transaction hash (positional)"
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
},
"wait": {
"type": "boolean",
"description": "Wait for transaction to be confirmed (default: false)",
"default": false
},
"timeout": {
"type": "number",
"description": "Timeout in seconds when waiting (default: 300)",
"default": 300
}
},
"required": ["hash"]
}Example:
controller transaction 0xTRANSACTION_HASH --chain-id SN_SEPOLIA --jsonExample (wait for confirmation):
controller transaction 0xTRANSACTION_HASH --chain-id SN_SEPOLIA --wait --json---
controller_receipt
Get the full transaction receipt including execution status, fee, events, and messages.
When to use: To get detailed information about a confirmed transaction, including events emitted and execution resources used.
Input Schema:
{
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "Transaction hash (positional)"
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
},
"wait": {
"type": "boolean",
"description": "Wait for receipt to be available (default: false)",
"default": false
},
"timeout": {
"type": "number",
"description": "Timeout in seconds when waiting (default: 300)",
"default": 300
}
},
"required": ["hash"]
}Example:
controller receipt 0xTRANSACTION_HASH --chain-id SN_SEPOLIA --jsonExample (wait for receipt):
controller receipt 0xTRANSACTION_HASH --chain-id SN_SEPOLIA --wait --json---
controller_balance
Query ERC20 token balances for the active session account.
When to use: To check token balances. Prefer this over raw call balance_of for common tokens.
Input Schema:
{
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Token symbol (e.g., 'eth', 'strk'). If omitted, queries all known tokens."
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
}
}
}Built-in tokens: ETH, STRK, USDC, USD.e, LORDS, SURVIVOR, WBTC. Custom tokens can be added via controller config set token.<SYMBOL> <address>.
Example:
controller balance --json
controller balance eth --json
controller balance --chain-id SN_MAIN --json---
controller_username
Display the Cartridge username associated with the active session account.
When to use: To find out the username for the currently active account.
Input Schema:
{
"type": "object",
"properties": {}
}Example:
controller username --json---
controller_lookup
Look up Cartridge controller addresses by usernames or usernames by addresses.
When to use: To resolve a username to an on-chain address, or find the username associated with an address.
Input Schema:
{
"type": "object",
"properties": {
"usernames": {
"type": "string",
"description": "Comma-separated usernames to resolve (e.g., 'shinobi,sensei')"
},
"addresses": {
"type": "string",
"description": "Comma-separated addresses to resolve (e.g., '0x123...,0x456...')"
}
}
}Note: Provide at least one of usernames or addresses. Both can be used in the same call.
Output: Array of username:address pairs
Example (by username):
controller lookup --usernames shinobi,sensei --jsonExample (by address):
controller lookup --addresses 0x123...,0x456... --json---
controller_config
Manage CLI configuration values.
When to use: To set, get, or list configuration values (e.g., default RPC URL, custom tokens).
Input Schema:
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["set", "get", "list"],
"description": "Config action to perform"
},
"key": {
"type": "string",
"description": "Config key (required for set/get). Valid: rpc-url, keychain-url, api-url, storage-path, json-output, colors, callback-timeout, token.<symbol>"
},
"value": {
"type": "string",
"description": "Value to set (required for set action)"
}
},
"required": ["action"]
}Example:
controller config set rpc-url https://api.cartridge.gg/x/starknet/mainnet
controller config get rpc-url --json
controller config list --json
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:
{
"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:
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:
{
"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:
controller marketplace buy --order-id 42 --collection 0x123...abc --token-id 1 --chain-id SN_MAIN --wait --jsonRequired Session Policies:
executeon marketplace contract (0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652)approveon payment token (e.g., STRK)
---
controller_starterpack_info
Get metadata for a starterpack (name, description, image, items).
When to use: To display starterpack details before purchasing.
Input Schema:
{
"type": "object",
"properties": {
"id": { "type": "string", "description": "Starterpack ID (decimal or hex)" },
"chain_id": { "type": "string", "description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')" },
"rpc_url": { "type": "string", "description": "RPC URL (overrides config, conflicts with chain_id)" }
},
"required": ["id"]
}Example:
controller starterpack info 1 --chain-id SN_MAIN --json---
controller_starterpack_quote
Get a price quote for a starterpack (payment token, fees, total cost).
When to use: To check the cost before purchasing.
Input Schema:
{
"type": "object",
"properties": {
"id": { "type": "string", "description": "Starterpack ID (decimal or hex)" },
"quantity": { "type": "number", "description": "Quantity to purchase (default: 1)", "default": 1 },
"chain_id": { "type": "string", "description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')" },
"rpc_url": { "type": "string", "description": "RPC URL (overrides config, conflicts with chain_id)" }
},
"required": ["id"]
}Example:
controller starterpack quote 1 --chain-id SN_MAIN --json---
controller_starterpack_purchase
Purchase a starterpack via UI (browser) or directly from Controller wallet.
When to use: To purchase a starterpack for the user or a recipient.
Input Schema:
{
"type": "object",
"properties": {
"id": { "type": "string", "description": "Starterpack ID (decimal or hex)" },
"ui": { "type": "boolean", "description": "Open browser UI for purchase (default mode). Supports crosschain payments and Apple Pay." },
"direct": { "type": "boolean", "description": "Execute purchase directly via Controller wallet session. Requires approve + issue policies." },
"recipient": { "type": "string", "description": "Recipient address (defaults to current controller). Direct mode only." },
"quantity": { "type": "number", "description": "Quantity to purchase (default: 1). Direct mode only.", "default": 1 },
"chain_id": { "type": "string", "description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')" },
"rpc_url": { "type": "string", "description": "RPC URL (overrides config, conflicts with chain_id)" },
"wait": { "type": "boolean", "description": "Wait for transaction confirmation. Direct mode only.", "default": false },
"timeout": { "type": "number", "description": "Timeout in seconds when waiting (default: 300). Direct mode only.", "default": 300 },
"no_paymaster": { "type": "boolean", "description": "Pay gas directly instead of using paymaster. Direct mode only.", "default": false }
},
"required": ["id"]
}`--ui` mode (default): Opens https://x.cartridge.gg/starterpack/<ID>/<CHAIN> in the browser. The user completes payment manually. Supports crosschain payments and Apple Pay.
`--direct` mode: Executes approve + issue on-chain using the active session. The session must have policies for:
approveon the payment token (check viastarterpack quote)issueon the starterpack contract (0x3eb03b8f2be0ec2aafd186d72f6d8f3dd320dbc89f2b6802bca7465f6ccaa43)
Example (UI):
controller starterpack purchase 1 --chain-id SN_MAIN
controller starterpack purchase 1 --ui --chain-id SN_MAINExample (direct):
controller starterpack purchase 1 --direct --chain-id SN_MAIN --json
controller starterpack purchase 1 --direct --recipient 0xABC... --quantity 2 --wait --json---
Calldata Formats
Calldata values support multiple formats:
| Format | Example | Description |
|---|---|---|
| Hex | 0x64 | Standard hex felt |
| Decimal | 100 | Decimal felt (auto-converted) |
u256: | u256:1000000000000000000 | Auto-splits into low/high 128-bit felts |
str: | str:hello | Cairo short string encoding |
The u256: prefix is the recommended way to specify token amounts. It eliminates manual low/high splitting.
Common Workflows
First-Time Setup
1. Check status: controller session status --json 2. Create policy file with desired contracts/methods 3. Authorize session: controller session auth --file policy.json --json (user must authorize in browser) 4. Execute transactions: controller execute ...
Transfer Tokens
# Check session is active
controller session status --json
# Transfer 1 STRK using u256: prefix
controller execute \
0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d \
transfer \
0xRECIPIENT_ADDRESS,u256:1000000000000000000 \
--jsonHandle Expired Session
If status shows expired: 1. Create/update policy file if needed 2. Run controller session auth --file policy.json --json 3. User authorizes in browser 4. Retry the transaction
Error Handling
NoSession
- Cause: No keypair found
- Fix: Run
controller session auth --file policy.json
SessionExpired
- Cause: Session expired
- Fix: Run
controller session auth --file policy.json(user must re-authorize)
ManualExecutionRequired
- Cause: No authorized session for this transaction
- Fix: Authorize session with appropriate policies
PolicyViolation
- Cause: Transaction not allowed by current session policies
- Fix: Authorize new session with expanded policies
Important Notes
1. Human Authorization Required: Sessions require browser authorization. The LLM cannot bypass this - always prompt the user to open the URL.
2. Session Expiration: Sessions expire. Always check status before transactions.
3. Calldata Prefixes: Use u256: for token amounts instead of manual low/high splitting. Use str: for Cairo short strings. Decimal values are supported without any prefix.
4. Subsidized Transactions: On Sepolia testnet, transactions are automatically subsidized (no ETH needed for gas).
5. Contract Addresses: Must be 32-byte hex with 0x prefix.
6. Always Use --json Flag: For machine-readable output that's easy to parse.
7. Use `balance` command: Prefer controller balance over raw call balance_of for token balance queries.
Common Contracts (Sepolia Testnet)
| Token | Address |
|---|---|
| STRK | 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d |
| ETH | 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 |
Example Conversation
User: "Send 100 STRK to 0xabc123"
Agent: [Checks status]
> controller session status --json
> Result: {"status": "no_session"}
Agent: "I need to set up a session first. Let me authorize one..."
> [Creates policy.json with STRK contract and transfer method]
Agent: "Now I need you to authorize this session. Please open this URL:"
> controller session auth --file policy.json --json
> Result: {"authorization_url": "https://x.cartridge.gg/session?...", "short_url": "https://api.cartridge.gg/s/abc123"}
Agent: "Please open the URL above and authorize the session. I'll wait..."
[User authorizes]
> Result: {"message": "Session authorized and stored successfully"}
Agent: "Great! Now executing the transfer..."
> controller execute 0x04718f5... transfer 0xabc123,u256:100000000000000000000 --json
> Result: {"transaction_hash": "0x789..."}
Agent: "Transfer submitted! Transaction hash: 0x789..."Security
- Private keys stored securely in
~/.config/controller-cli/ - Sessions limit what contracts/methods can be called
- Human authorization required for all sessions
- Sessions expire automatically
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
{
"contracts": {
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "ETH Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer ETH tokens to another address"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve another address to spend ETH tokens"
}
]
}
}
}
{
"contracts": {
"0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": {
"name": "STRK Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer STRK tokens"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve STRK token spending"
}
]
},
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "ETH Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer ETH tokens"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve ETH token spending"
}
]
}
}
}
{
"contracts": {
"0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": {
"name": "STRK Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer STRK tokens to another address"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve another address to spend STRK tokens"
}
]
}
}
}
Controller CLI Skill
An MCP skill that enables LLMs to execute Starknet transactions using Cartridge Controller sessions.
Installation
For Claude Code
# From the controller-cli repo root
ln -s "$(pwd)/.claude/skills/controller-skill" ~/.claude/skills/controller-skillOr install directly:
# Copy skill to Claude skills directory
cp -r .claude/skills/controller-skill ~/.claude/skills/For Cursor
# Link to Cursor skills directory
ln -s "$(pwd)/.claude/skills/controller-skill" ~/.cursor/skills/controller-skillPrerequisites
1. Install Controller CLI:
curl -fsSL https://raw.githubusercontent.com/cartridge-gg/controller-cli/main/install.sh | bash2. Verify Installation:
controller --versionQuick Start
Once the skill is installed, you can ask Claude to:
- "Check my controller session status"
- "Authorize a new session for STRK transfers"
- "Send 100 STRK to 0xabc123"
- "Check my token balances"
- "What's my username?"
- "Look up the address for username shinobi"
- "Get the receipt for transaction 0x123..."
Example Usage
Check Status
You: "Check if I have an active controller session"
Claude: [Uses controller_session_status tool]
Claude: "You don't have an active session. Would you like me to set one up?"Setup Session
You: "Set up a session for STRK token transfers"
Claude: [Creates policy file]
Claude: [Uses controller_session_auth]
Claude: "Please open this URL to authorize: https://x.cartridge.gg/session?..."
You: [Opens URL and authorizes]
Claude: "Session authorized! You can now transfer STRK tokens."Execute Transaction
You: "Send 1 STRK to 0x123abc..."
Claude: [Uses controller_execute with u256: prefix]
Claude: "Transaction submitted! Hash: 0x789..."Check Balances
You: "What are my token balances?"
Claude: [Uses controller_balance]
Claude: "Your balances: 0.5 ETH, 100.0 STRK"Read-Only Call
You: "Check the STRK balance of 0x456..."
Claude: [Uses controller_call]
Claude: "The balance is 1000 STRK"Check Transaction
You: "What's the status of transaction 0x789...?"
Claude: [Uses controller_transaction]
Claude: "Transaction 0x789... has been confirmed."Get Receipt
You: "Show me the receipt for 0x789..."
Claude: [Uses controller_receipt]
Claude: "Transaction SUCCEEDED. Fee: 0x... FRI. 3 events emitted."Policy Files
The skill includes example policy files in the examples/ directory:
strk-token-policy.json- STRK token transfers and approvalseth-token-policy.json- ETH token transfers and approvalsmulti-token-policy.json- Both STRK and ETH tokens
You can create custom policy files for your specific contracts and methods.
Tools Available
Session Management
1. controller_session_auth - Generate keypair and authorize a new session (combines old generate + register) 2. controller_session_status - Check session status and expiration 3. controller_session_list - List all active sessions with pagination 4. controller_session_clear - Clear all session data
Transaction Execution
5. controller_execute - Execute transactions (positional args: contract, entrypoint, calldata) 6. controller_call - Read-only contract calls (positional args: contract, entrypoint, calldata)
Transaction Queries
7. controller_transaction - Get transaction status and details 8. controller_receipt - Get full transaction receipt (fee, events, execution resources)
Account & Identity
9. controller_balance - Query ERC20 token balances (ETH, STRK, USDC, and more) 10. controller_username - Get the account's Cartridge username 11. controller_lookup - Look up usernames/addresses
Configuration
12. controller_config_set - Set a config value 13. controller_config_get - Get a config value 14. controller_config_list - List all config values
Starterpacks
15. controller_starterpack_info - Get starterpack metadata (name, description, items) 16. controller_starterpack_quote - Get price quote (payment token, fees, total cost) 17. controller_starterpack_purchase - Purchase a starterpack (--ui for browser/crosschain/Apple Pay, --direct for on-chain via session)
Starterpacks
You: "What's in starterpack #1?"
Claude: [Uses controller starterpack info 1 --chain-id SN_MAIN --json]
Claude: "Starterpack #1 'Battle Kit' contains: Sword, Shield, 100 Gold"You: "How much does it cost?"
Claude: [Uses controller starterpack quote 1 --chain-id SN_MAIN --json]
Claude: "Total cost: 10.70 STRK (base: 10.00, fees: 0.70)"You: "Buy it for me"
Claude: "Would you like to purchase via browser (supports crosschain/Apple Pay) or directly from your wallet?"
You: "Browser"
Claude: [Uses controller starterpack purchase 1 --chain-id SN_MAIN]
Claude: "Opening the purchase page — complete payment in your browser."Calldata Formats
Calldata values support multiple formats:
| Format | Example | Description |
|---|---|---|
| Hex | 0x64 | Standard hex felt |
| Decimal | 100 | Decimal felt |
u256: | u256:1000000000000000000 | Auto-splits into low/high 128-bit felts |
str: | str:hello | Cairo short string |
Security
- Private keys stored securely in
~/.config/controller-cli/ - Human authorization required for all sessions
- Sessions limit which contracts/methods can be called
- Sessions expire automatically
Common Workflows
First-Time Setup
1. Check status (session status) 2. Create policy file (or use example/preset) 3. Authorize session (session auth) - user authorizes in browser 4. Execute transactions
Daily Use
1. Check status 2. Execute transactions 3. If expired, re-authorize session
Troubleshooting
"No session found"
- Run
controller session status - Authorize a new session with
controller session auth
"Session expired"
- Authorize new session with same policy file
- User must re-authorize in browser
"Policy violation"
- Transaction not allowed by current policies
- Authorize new session with expanded policies
Support
- Repository: https://github.com/cartridge-gg/controller-cli
- Issues: https://github.com/cartridge-gg/controller-cli/issues
- Documentation: See
skill.mdfor detailed tool documentation
{
"name": "controller",
"description": "Execute Starknet transactions using Cartridge Controller sessions with human-authorized policies",
"version": "0.2.0",
"tools": [
{
"name": "controller_session_auth",
"description": "Generate a keypair and authorize a new session. Combines keypair generation and session registration in a single step. IMPORTANT: Requires human to authorize via browser. Display the authorization URL to the user and wait for them to authorize.",
"inputSchema": {
"type": "object",
"properties": {
"policy_file": {
"type": "string",
"description": "Path to JSON policy file defining allowed contracts and methods"
},
"preset": {
"type": "string",
"description": "Preset name (e.g., 'loot-survivor'). Alternative to policy_file."
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
}
},
"anyOf": [
{"required": ["policy_file"]},
{"required": ["preset"]}
]
},
"command": "controller session auth {policy_file:--file} {preset:--preset} {chain_id:--chain-id} {rpc_url:--rpc-url} --json"
},
{
"name": "controller_session_status",
"description": "Check current session status, expiration time, and keypair information. Use before executing transactions.",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
},
"command": "controller session status --json"
},
{
"name": "controller_session_list",
"description": "List all active sessions for the account with pagination support.",
"inputSchema": {
"type": "object",
"properties": {
"chain_id": {
"type": "string",
"description": "Chain ID to filter sessions (defaults to session chain)"
},
"limit": {
"type": "number",
"description": "Sessions per page (default: 10)",
"default": 10
},
"page": {
"type": "number",
"description": "Page number starting from 1 (default: 1)",
"default": 1
}
}
},
"command": "controller session list {chain_id:--chain-id} {limit:--limit} {page:--page} --json"
},
{
"name": "controller_session_clear",
"description": "Clear all stored session data and keypairs. Use to reset or troubleshoot.",
"inputSchema": {
"type": "object",
"properties": {
"yes": {
"type": "boolean",
"description": "Skip confirmation prompt",
"default": true
}
}
},
"command": "controller session clear {yes:--yes}"
},
{
"name": "controller_execute",
"description": "Execute a Starknet transaction using the active session. Supports single calls or multiple calls from file. Transactions are automatically subsidized on Sepolia.",
"inputSchema": {
"type": "object",
"properties": {
"contract": {
"type": "string",
"description": "Contract address (hex with 0x prefix). Use with entrypoint and calldata for single call."
},
"entrypoint": {
"type": "string",
"description": "Function name to call. Use with contract and calldata for single call."
},
"calldata": {
"type": "string",
"description": "Comma-separated calldata values. Supports hex, decimal, u256:, and str: prefixes."
},
"file": {
"type": "string",
"description": "Path to JSON file with multiple calls. Alternative to contract/entrypoint/calldata."
},
"wait": {
"type": "boolean",
"description": "Wait for transaction confirmation. Default: false",
"default": false
},
"timeout": {
"type": "number",
"description": "Timeout in seconds when waiting. Default: 300",
"default": 300
}
},
"anyOf": [
{"required": ["contract", "entrypoint", "calldata"]},
{"required": ["file"]}
]
},
"command": "controller execute {contract:--contract} {entrypoint:--entrypoint} {calldata:--calldata} {file:--file} {wait:--wait} {timeout:--timeout} --json"
},
{
"name": "controller_call",
"description": "Execute a read-only call to a contract (no session required). Use to query contract state.",
"inputSchema": {
"type": "object",
"properties": {
"contract": {
"type": "string",
"description": "Contract address (hex with 0x prefix)"
},
"entrypoint": {
"type": "string",
"description": "Function name to call"
},
"calldata": {
"type": "string",
"description": "Comma-separated calldata values (hex with 0x prefix)"
},
"file": {
"type": "string",
"description": "Path to JSON file with multiple calls"
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
},
"block_id": {
"type": "string",
"description": "Block ID to query (latest, pending, block number, or block hash)"
}
}
},
"command": "controller call {contract} {entrypoint} {calldata} {file:--file} {chain_id:--chain-id} {rpc_url:--rpc-url} {block_id:--block-id} --json"
},
{
"name": "controller_transaction",
"description": "Get transaction status and details. Use to check if a transaction has been confirmed.",
"inputSchema": {
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "Transaction hash"
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
},
"wait": {
"type": "boolean",
"description": "Wait for transaction to be confirmed (default: false)",
"default": false
},
"timeout": {
"type": "number",
"description": "Timeout in seconds when waiting (default: 300)",
"default": 300
}
},
"required": ["hash"]
},
"command": "controller transaction {hash} {chain_id:--chain-id} {rpc_url:--rpc-url} {wait:--wait} {timeout:--timeout} --json"
},
{
"name": "controller_receipt",
"description": "Get the full transaction receipt including execution status, fee, events, messages, and execution resources.",
"inputSchema": {
"type": "object",
"properties": {
"hash": {
"type": "string",
"description": "Transaction hash"
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
},
"wait": {
"type": "boolean",
"description": "Wait for receipt to be available (default: false)",
"default": false
},
"timeout": {
"type": "number",
"description": "Timeout in seconds when waiting (default: 300)",
"default": 300
}
},
"required": ["hash"]
},
"command": "controller receipt {hash} {chain_id:--chain-id} {rpc_url:--rpc-url} {wait:--wait} {timeout:--timeout} --json"
},
{
"name": "controller_balance",
"description": "Query ERC20 token balances for the active session account. Built-in tokens: ETH, STRK, USDC, USD.e, LORDS, SURVIVOR, WBTC. Prefer this over raw call balance_of.",
"inputSchema": {
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Token symbol (e.g., 'eth', 'strk'). If omitted, queries all known tokens."
},
"chain_id": {
"type": "string",
"description": "Chain ID (e.g., 'SN_MAIN' or 'SN_SEPOLIA')"
},
"rpc_url": {
"type": "string",
"description": "RPC URL (overrides config, conflicts with chain_id)"
}
}
},
"command": "controller balance {symbol} {chain_id:--chain-id} {rpc_url:--rpc-url} --json"
},
{
"name": "controller_username",
"description": "Display the Cartridge username associated with the active session account.",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
},
"command": "controller username --json"
},
{
"name": "controller_lookup",
"description": "Look up Cartridge controller addresses by usernames or usernames by addresses. Returns username:address pairs.",
"inputSchema": {
"type": "object",
"properties": {
"usernames": {
"type": "string",
"description": "Comma-separated usernames to resolve to addresses (e.g., 'shinobi,sensei')"
},
"addresses": {
"type": "string",
"description": "Comma-separated addresses to resolve to usernames (e.g., '0x123...,0x456...')"
}
},
"anyOf": [
{"required": ["usernames"]},
{"required": ["addresses"]}
]
},
"command": "controller lookup {usernames:--usernames} {addresses:--addresses} --json"
},
{
"name": "controller_config_set",
"description": "Set a CLI configuration value.",
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Config key (e.g., rpc-url, json-output, token.<symbol>)"
},
"value": {
"type": "string",
"description": "Value to set"
}
},
"required": ["key", "value"]
},
"command": "controller config set {key} {value}"
},
{
"name": "controller_config_get",
"description": "Get a CLI configuration value.",
"inputSchema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Config key (e.g., rpc-url, json-output, token.<symbol>)"
}
},
"required": ["key"]
},
"command": "controller config get {key} --json"
},
{
"name": "controller_config_list",
"description": "List all CLI configuration values.",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
},
"command": "controller config list --json"
}
],
"examples": [
{
"title": "Check Session Status",
"tool": "controller_session_status",
"input": {}
},
{
"title": "Authorize Session with Preset",
"tool": "controller_session_auth",
"input": {
"preset": "loot-survivor",
"chain_id": "SN_MAIN"
}
},
{
"title": "Authorize Session with Policy File",
"tool": "controller_session_auth",
"input": {
"policy_file": "policy.json"
}
},
{
"title": "Transfer Tokens (u256 prefix)",
"tool": "controller_execute",
"input": {
"contract": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"entrypoint": "transfer",
"calldata": "0xRECIPIENT,u256:1000000000000000000"
}
},
{
"title": "Execute Multiple Calls",
"tool": "controller_execute",
"input": {
"file": "calls.json",
"wait": true
}
},
{
"title": "Check Token Balance",
"tool": "controller_balance",
"input": {
"symbol": "eth"
}
},
{
"title": "Get Transaction Receipt",
"tool": "controller_receipt",
"input": {
"hash": "0xTRANSACTION_HASH",
"chain_id": "SN_SEPOLIA"
}
},
{
"title": "Get Account Username",
"tool": "controller_username",
"input": {}
},
{
"title": "Look Up Usernames",
"tool": "controller_lookup",
"input": {
"usernames": "shinobi,sensei"
}
},
{
"title": "List Active Sessions",
"tool": "controller_session_list",
"input": {}
}
],
"commonContracts": {
"sepolia": {
"STRK": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d",
"ETH": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"
}
}
}
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo dependencies
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check compilation
run: cargo check
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo dependencies
uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo dependencies
uses: Swatinem/rust-cache@v2
- name: Build release
run: cargo build --release
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Allow Claude to run Rust-specific commands
allowed_tools: "Bash(cargo build),Bash(cargo test),Bash(cargo check),Bash(cargo clippy),Bash(cargo fmt -- --check)"
# Custom instructions for Rust CLI project
custom_instructions: |
This is a Rust CLI tool for Cartridge Controller session management.
- Follow Rust best practices and idioms
- Ensure all changes compile with `cargo check`
- Run `cargo clippy` to check for common mistakes
- Maintain consistent error handling using the CliError type
- Keep JSON output format stable for LLM compatibility
- Test changes that affect session management or RPC communication
- Update LLM_USAGE.md if CLI behavior changes
name: Release Dispatch
on:
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g. 0.1.12)"
required: true
type: string
permissions:
pull-requests: write
contents: write
jobs:
propose-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Bump version in Cargo.toml
run: |
VERSION=${{ github.event.inputs.version }}
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
echo "Updated Cargo.toml to version ${VERSION}"
grep '^version' Cargo.toml
- name: Update Cargo.lock
run: cargo generate-lockfile
- name: Get commit range for changelog
id: commit_range
run: |
LATEST_TAG=$(gh release list --limit 1 --exclude-pre-releases --exclude-drafts --json tagName --jq '.[0].tagName' || echo "")
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG=$(git log --oneline -n 50 | tail -1 | cut -d' ' -f1)
fi
echo "RANGE=${LATEST_TAG}..HEAD" >> $GITHUB_OUTPUT
echo "Changelog range: ${LATEST_TAG}..HEAD"
# Generate simple changelog from commit messages
echo "## Changes" > changelog.md
echo "" >> changelog.md
git log --pretty=format:"- %s" ${LATEST_TAG}..HEAD >> changelog.md
echo "" >> changelog.md
cat changelog.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release Pull Request
uses: peter-evans/create-pull-request@v5
with:
title: "chore: prepare release ${{ github.event.inputs.version }}"
commit-message: "chore: bump version to ${{ github.event.inputs.version }}"
branch: prepare-release
base: main
delete-branch: true
body: |
## Release v${{ github.event.inputs.version }}
This PR bumps the version to `${{ github.event.inputs.version }}`.
**When merged**, the release workflow will automatically:
1. Create the `cli-v${{ github.event.inputs.version }}` tag
2. Build binaries for all platforms
3. Create a GitHub release with the binaries attached
### Changelog
${{ steps.commit_range.outputs.RANGE }}
name: Release
on:
push:
tags:
- 'cli-v*'
pull_request:
types: [closed]
branches:
- main
workflow_dispatch:
inputs:
tag:
description: 'Tag to release'
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
create-tag:
name: Create Tag
if: |
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref == 'prepare-release')
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.extract.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Extract version and create tag
id: extract
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
TAG="cli-v${VERSION}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Creating tag: ${TAG}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "${TAG}"
git push origin "${TAG}"
build:
name: Build ${{ matrix.target }}
needs: [create-tag]
if: |
always() &&
(needs.create-tag.result == 'success' ||
needs.create-tag.result == 'skipped') &&
!(github.event_name == 'pull_request' &&
(github.event.pull_request.merged != true ||
github.event.pull_request.head.ref != 'prepare-release'))
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: controller
asset_name: controller-x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact_name: controller
asset_name: controller-aarch64-unknown-linux-gnu
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: controller
asset_name: controller-x86_64-apple-darwin
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: controller
asset_name: controller-aarch64-apple-darwin
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-tag.outputs.tag || github.ref }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux ARM)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Build
run: |
cargo build --release --target ${{ matrix.target }}
- name: Package
run: |
cd target/${{ matrix.target }}/release
tar czf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
mv ${{ matrix.asset_name }}.tar.gz ${{ github.workspace }}/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}.tar.gz
release:
name: Create Release
needs: [create-tag, build]
if: always() && needs.build.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Get tag name
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" = "pull_request" ]; then
echo "tag=${{ needs.create-tag.outputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Controller CLI ${{ steps.tag.outputs.tag }}
draft: false
prerelease: false
files: artifacts/**/*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Release summary
run: |
echo "✅ Release created: ${{ steps.tag.outputs.tag }}"
echo ""
echo "Install with:"
echo "curl -fsSL https://raw.githubusercontent.com/cartridge-gg/controller-cli/main/install.sh | bash"
/target
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` 3. Check existing patterns — Follow the style of existing commands in src/commands/
Code Style
Rust Standards
- Format: Run
cargo fmtbefore committing - Lint: Run
cargo clippyand fix warnings - Build: Ensure
cargo buildsucceeds - Test: Run
cargo testif tests exist
CLI Conventions
- All commands support
--jsonfor machine-readable output - Use the
JsonOutputstruct for consistent response format - Include
error_code,message, andrecovery_hintin error responses - Add new commands to
src/commands/mod.rs
Documentation
- Update
LLM_USAGE.mdwhen adding/modifying commands - Update
SKILL.mdwhen adding new tools for agents - Include examples in doc comments
PR Guidelines
Before Opening a PR
# Required checks
cargo fmt
cargo clippy
cargo build
cargo test # if tests existPR Title Format
type(scope): description
# Examples:
feat(marketplace): add buy and info commands
fix(session): handle expired token refresh
docs(readme): add calldata format examplesTypes: 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
#123if 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
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
--jsonoutput for programmatic access (no parsing stdout text)
Getting Help
[package]
name = "controller-cli"
version = "0.1.16"
edition = "2021"
authors = ["Cartridge <engineering@cartridge.gg>"]
description = "CLI for Cartridge Controller session management"
license = "MIT"
repository = "https://github.com/cartridge-gg/controller-cli"
[[bin]]
name = "controller"
path = "src/main.rs"
[dependencies]
# Use existing account_sdk from controller-rs
# Local development: use path dependency
# account_sdk = { path = "../controller-rs/account_sdk", features = ["filestorage"] }
# Release builds: use git dependency
account_sdk = { git = "https://github.com/cartridge-gg/controller-rs", tag = "v0.9.2", package = "account_sdk", features = ["filestorage"] }
# CLI framework
clap = { version = "4.5", features = ["derive", "env"] }
# HTTP client for API queries (using rustls for better cross-compilation)
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# HTTP server for callback
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
# Async runtime (already in account_sdk)
tokio = { version = "1", features = ["full"] }
# Serialization (already in account_sdk)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
erased-serde = "0.4"
# Async utilities
futures = "0.3"
# Error handling (already in account_sdk)
anyhow = "1"
thiserror = "1"
# Encoding
base64 = "0.22"
hex = "0.4"
url = "2.3"
webbrowser = "1.0"
# Terminal output
colored = "2.1"
indicatif = "0.17"
# Time handling (already in account_sdk)
chrono = { version = "0.4", features = ["serde"] }
# StarkNet (already in account_sdk, but explicit for clarity)
starknet = "0.17.0"
starknet-crypto = "0.8.1"
cainome-cairo-serde = "0.4.0"
# Config file parsing
toml = "0.8"
dirs = "5.0"
shellexpand = "3.1"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
Changes
- feat: add --expires flag for session auth expiration (#51)
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:
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.
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:
approveon payment token (ERC20)executeon marketplace contract
controller marketplace info
Query order/listing details before purchasing.
controller marketplace info \
--order-id 42 \
--collection 0x123...abc \
--token-id 1 \
[--chain-id SN_MAIN|SN_SEPOLIA] \
[--json]Output:
{
"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.
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):
fn encode_u256(value: U256) -> Vec<Felt> {
vec![
Felt::from(value.low), // u128 low bits
Felt::from(value.high), // u128 high bits
]
}Execute Calldata
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
#[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
#[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 moduleImplementation 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_validityfor confirmation
2. Client fee handling - should CLI pass 0 or allow configuration?
- Recommendation: Default to 0, add optional
--client-feeflag later
Appendix: Arcade Marketplace 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 }
}
}
}
}{
"calls": [
{
"contractAddress": "0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d",
"entrypoint": "transfer",
"calldata": [
"0x0000000000000000000000000000000000000000000000000000000000000001",
"0x0",
"0x0"
]
}
]
}
{
"contracts": {
"0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "ETH Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer ETH tokens to another address"
}
]
},
"0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": {
"name": "STRK Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer STRK tokens to another address"
}
]
}
}
}
#!/bin/bash
set -e
# Cartridge Controller CLI Installer
# Usage: curl -fsSL https://raw.githubusercontent.com/cartridge-gg/controller/main/controller-cli/install.sh | bash
REPO="cartridge-gg/controller-cli"
BINARY_NAME="controller"
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# Detect platform
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux*)
PLATFORM="unknown-linux-gnu"
;;
Darwin*)
PLATFORM="apple-darwin"
;;
*)
echo "Unsupported operating system: $OS"
exit 1
;;
esac
case "$ARCH" in
x86_64)
ARCH="x86_64"
;;
arm64|aarch64)
ARCH="aarch64"
;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
TARGET="${ARCH}-${PLATFORM}"
echo "Detected platform: $TARGET"
# Get latest release
echo "Fetching latest release..."
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$LATEST_RELEASE" ]; then
echo "Failed to fetch latest release"
exit 1
fi
echo "Latest release: $LATEST_RELEASE"
# Download URL
DOWNLOAD_URL="https://github.com/$REPO/releases/download/$LATEST_RELEASE/${BINARY_NAME}-${TARGET}.tar.gz"
echo "Downloading from: $DOWNLOAD_URL"
# Create temp directory
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
# Download and extract
cd "$TMP_DIR"
curl -fsSL "$DOWNLOAD_URL" | tar xz
# Create install directory if it doesn't exist
mkdir -p "$INSTALL_DIR"
# Install binary
mv "$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
echo ""
echo "✅ $BINARY_NAME installed successfully to $INSTALL_DIR"
echo ""
# Check if install dir is in PATH
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo "⚠️ Add $INSTALL_DIR to your PATH:"
echo ""
echo " export PATH=\"\$PATH:$INSTALL_DIR\""
echo ""
echo "Add this line to your ~/.bashrc, ~/.zshrc, or ~/.profile"
echo ""
fi
# Verify installation
if command -v "$BINARY_NAME" &> /dev/null; then
echo "Run '$BINARY_NAME --help' to get started"
else
echo "Run '$INSTALL_DIR/$BINARY_NAME --help' to get started"
fi
LLM Usage Guide
Instructions for LLMs to install and use the Cartridge Controller CLI for executing Starknet transactions.
Installation
Step 1: Install the CLI binary
curl -fsSL https://raw.githubusercontent.com/cartridge-gg/controller-cli/main/install.sh | bashIf the installation directory is not in PATH, add it:
export PATH="$PATH:$HOME/.local/bin"Verify:
controller --versionStep 2: Install the skill (Recommended)
The skill provides structured tools with automatic JSON parsing and better error handling.
git clone https://github.com/cartridge-gg/controller-cli.git /tmp/controller-cli && \
mkdir -p ~/.claude/skills && \
ln -sf /tmp/controller-cli/.claude/skills/controller-skill ~/.claude/skills/controller-skillOnce installed, tools become available:
controller_session_auth- Generate keypair and authorize a new sessioncontroller_session_status- Check session statuscontroller_session_list- List active sessionscontroller_session_clear- Clear session datacontroller_execute- Execute transactionscontroller_call- Read-only contract callscontroller_transaction- Get transaction statuscontroller_receipt- Get transaction receiptcontroller_balance- Check token balancescontroller_username- Get account usernamecontroller_lookup- Look up usernames/addressescontroller_config- Manage CLI configurationcontroller_starterpack_info- Get starterpack metadatacontroller_starterpack_quote- Get starterpack price quotecontroller_starterpack_purchase- Purchase a starterpack (UI or direct)
See: Skill Documentation
---
Workflow
1. Check Status
controller session status --jsonStatus states:
no_session- No keypair existskeypair_only- Keypair exists but no registered sessionactive- Session registered and not expired
Active session output:
{
"status": "active",
"session": {
"address": "0x...",
"chain_id": "SN_SEPOLIA",
"expires_at": 1735689600,
"expires_in_seconds": 3600,
"expires_at_formatted": "2025-01-01 00:00:00 UTC",
"is_expired": false
},
"keypair": {
"public_key": "0x...",
"has_private_key": true
}
}2. Authorize Session
Requirements: Human user must authorize via browser. Specify either a preset or a local policy file, plus a network.
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.
`--expires` flag: Use --expires <duration> to set the session expiration. Accepts human-readable durations: 1min, 1hr, 1day, 7days, 1week, 1year (plurals supported for day/week/year). Defaults to 7days.
Option A: Use a Preset (Recommended)
For popular games/apps, use a preset from cartridge-gg/presets:
controller session auth \
--preset loot-survivor \
--chain-id SN_MAIN \
--jsonWith a specific account:
controller session auth \
--preset loot-survivor \
--chain-id SN_MAIN \
--account shinobi \
--jsonWith custom expiration:
controller session auth \
--preset loot-survivor \
--chain-id SN_MAIN \
--expires 1hr \
--jsonAvailable presets: loot-survivor, influence, realms, pistols, dope-wars, and more.
Option B: Use a Local Policy File
Create policy.json:
{
"contracts": {
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "STRK Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer STRK tokens"
}
]
}
}
}controller session auth \
--file policy.json \
--rpc-url https://api.cartridge.gg/x/starknet/sepolia \
--jsonAuthorization Flow
JSON output:
{
"authorization_url": "https://x.cartridge.gg/session?public_key=0x...&policies=...",
"short_url": "https://api.cartridge.gg/s/abc123",
"public_key": "0x...",
"message": "Open this URL in your browser to authorize the session. Waiting for authorization..."
}Important: 1. Display the short_url (if present) to the user, otherwise fall back to authorization_url 2. Ask them to open it in their browser and authorize 3. The command waits automatically and stores the session when authorized (up to 6 minutes)
Background Execution
The session auth command blocks for up to 6 minutes while waiting for the user to authorize in the browser. To avoid blocking your main thread, run it as a background process:
1. Start session auth in the background 2. Capture and display the short_url to the user immediately (fall back to authorization_url if unavailable) 3. Poll the process for completion 4. Once it exits successfully, verify with controller session status --json
This keeps the agent responsive to other user requests while waiting for authorization.
3. Execute Transaction
Single call (positional args: contract, entrypoint, calldata):
controller execute \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
transfer \
0xRECIPIENT_ADDRESS,u256:1000000000000000000 \
--jsonMultiple calls from file (`calls.json`):
{
"calls": [
{
"contractAddress": "0x049d36...",
"entrypoint": "approve",
"calldata": ["0xSPENDER", "0xFFFFFFFF", "0xFFFFFFFF"]
},
{
"contractAddress": "0x123abc...",
"entrypoint": "swap",
"calldata": ["0x100", "0x0", "0x1"]
}
]
}controller execute \
--file calls.json \
--jsonOutput:
{
"transaction_hash": "0x...",
"message": "Transaction submitted successfully"
}Transaction Explorer Links: Always use Voyager:
- Mainnet:
https://voyager.online/tx/0x... - Sepolia:
https://sepolia.voyager.online/tx/0x...
4. Read-Only Call
Execute a read-only call to query contract state without submitting a transaction.
Single call (positional args: contract, entrypoint, calldata):
controller call \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
balance_of \
0xADDRESS \
--chain-id SN_SEPOLIA \
--jsonQuery at a specific block:
controller call \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
balance_of \
0xADDRESS \
--chain-id SN_SEPOLIA \
--block-id latest \
--jsonMultiple calls from file:
controller call --file calls.json --chain-id SN_SEPOLIA --jsonNote: call does not require an active session. It only needs a network (via --chain-id or --rpc-url).
5. Get Transaction Status
Check the status and details of a submitted transaction.
controller transaction 0xTRANSACTION_HASH \
--chain-id SN_SEPOLIA \
--jsonWait for confirmation:
controller transaction 0xTRANSACTION_HASH \
--chain-id SN_SEPOLIA \
--wait \
--timeout 300 \
--json6. Get Transaction Receipt
Get the full transaction receipt including execution status, fee, events, and messages.
controller receipt 0xTRANSACTION_HASH \
--chain-id SN_SEPOLIA \
--jsonWait for receipt to be available:
controller receipt 0xTRANSACTION_HASH \
--chain-id SN_SEPOLIA \
--wait \
--timeout 300 \
--json7. Check Token Balances
Query ERC20 token balances for the active session account.
# All non-zero balances
controller balance --json
# Specific token
controller balance eth --json
# Query on mainnet
controller balance --chain-id SN_MAIN --jsonBuilt-in tokens: ETH, STRK, USDC, USD.e, LORDS, SURVIVOR, WBTC. Add custom tokens:
controller config set token.MYTOKEN 0x123...Output:
[
{ "token": "ETH", "balance": "0.500000", "raw": "0x6f05b59d3b20000", "contract": "0x049d36..." },
{ "token": "STRK", "balance": "100.000000", "raw": "0x56bc75e2d63100000", "contract": "0x04718f..." }
]8. Get Account Username
Display the Cartridge username for the active session account.
controller username --json9. Look Up Usernames / Addresses
Resolve Cartridge controller usernames to addresses or vice versa:
# Look up addresses for usernames
controller lookup --usernames shinobi,sensei --json# Look up usernames for addresses
controller lookup --addresses 0x123...,0x456... --jsonOutput:
{
"status": "success",
"data": [
"shinobi:0x123...",
"sensei:0x456..."
]
}Each entry is a username:address pair. You can combine both flags in a single call. See the Cartridge Usernames API for limits and rate-limiting details.
10. Session Management
List active sessions:
controller session list --json
controller session list --limit 20 --page 2 --jsonClear all session data:
controller session clear --yes11. Configuration
Manage CLI settings without editing the config file directly.
# Set a value
controller config set rpc-url https://api.cartridge.gg/x/starknet/mainnet
# Get a value
controller config get rpc-url --json
# List all values
controller config list --jsonValid keys: rpc-url, keychain-url, api-url, storage-path, json-output, colors, callback-timeout, token.<symbol>.
12. Starterpacks
Query starterpack info, get price quotes, and purchase starterpacks.
Get starterpack metadata:
controller starterpack info <ID> --chain-id SN_MAIN --jsonGet a price quote:
controller starterpack quote <ID> --chain-id SN_MAIN --jsonOutput:
{
"starterpack_id": "1",
"chain_id": "SN_MAIN",
"payment_token": "0x04718f...",
"base_price": "10.000000",
"referral_fee": "0.500000",
"protocol_fee": "0.200000",
"total_cost": "10.700000"
}Purchase via UI (default — opens browser):
controller starterpack purchase <ID> --chain-id SN_MAIN
# or explicitly:
controller starterpack purchase <ID> --ui --chain-id SN_MAINOpens https://x.cartridge.gg/starterpack/<ID>/<CHAIN> in the user's browser. The UI supports crosschain payments and Apple Pay — use this when the user wants flexible payment options or doesn't have an active session.
Purchase directly via Controller wallet:
controller starterpack purchase <ID> --direct --chain-id SN_MAIN --jsonExecutes approve + issue on-chain using the active session. Requires session policies that include:
approveon the payment token (returned byquote)issueon the starterpack contract (0x3eb03b8f2be0ec2aafd186d72f6d8f3dd320dbc89f2b6802bca7465f6ccaa43)
Additional flags for --direct:
--recipient <ADDRESS>— Purchase for a different address (defaults to controller)--quantity <N>— Number to purchase (default: 1)--wait— Wait for transaction confirmation--timeout <SECONDS>— Timeout when waiting (default: 300)--no-paymaster— Pay gas directly instead of using paymaster
When to use `--ui` vs `--direct`:
--ui(default): User wants crosschain payment, Apple Pay, or doesn't have a session with the right policies--direct: Automated/scripted purchases where the session already hasapprove+issuepolicies authorized
---
12. Marketplace Commands
Buy NFTs from the Arcade marketplace.
Query Order Info
Check if an order is valid before purchasing:
controller marketplace info \
--order-id 42 \
--collection 0x123...abc \
--token-id 1 \
--chain-id SN_MAIN \
--jsonOutput:
{
"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:
controller marketplace buy \
--order-id 42 \
--collection 0x123...abc \
--token-id 1 \
--chain-id SN_MAIN \
--wait \
--jsonOptions:
--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:
executeon the marketplace contract (0x057b4ca2f7b58e1b940eb89c4376d6e166abc640abf326512b0c77091f3f9652)approveon the payment token (e.g., STRK)
---
Calldata Formats
Calldata values support multiple formats:
| Format | Example | Description |
|---|---|---|
| Hex | 0x64 | Standard hex felt |
| Decimal | 100 | Decimal felt (auto-converted) |
u256: | u256:1000000000000000000 | Auto-splits into low/high 128-bit felts |
str: | str:hello | Cairo short string encoding |
bytearray: | bytearray:hello | Cairo ByteArray multi-felt serialization |
bytearray: (quoted) | bytearray:"hello world" | ByteArray with spaces (quotes stripped) |
bytearray: (raw) | bytearray:[0x48,0x65,0x6c,0x6c,0x6f] | ByteArray from raw byte values |
The u256: prefix is the recommended way to specify token amounts. It eliminates manual low/high splitting:
# Using u256: prefix (recommended)
controller execute 0x04718f... transfer 0xRECIPIENT,u256:1000000000000000000 --json
# Equivalent manual split
controller execute 0x04718f... transfer 0xRECIPIENT,0xDE0B6B3A7640000,0x0 --jsonThe bytearray: prefix serializes strings or raw bytes into Cairo's ByteArray format (data chunks + pending word + length). Use it for contract entrypoints that expect a ByteArray argument:
# String mode (simple, no spaces)
controller execute 0x... set_name bytearray:MyName --json
# Quoted string mode (use quotes for strings with spaces)
controller execute 0x... set_name 'bytearray:"My Game Name"' --json
# Raw bytes mode
controller execute 0x... set_data bytearray:[0x48,0x65,0x6c,0x6c,0x6f] --json---
Network Selection
Always be explicit about network. Never rely on defaults.
Supported Networks
| Chain ID | RPC URL | Usage |
|---|---|---|
SN_MAIN | https://api.cartridge.gg/x/starknet/mainnet | Starknet Mainnet |
SN_SEPOLIA | https://api.cartridge.gg/x/starknet/sepolia | Starknet Sepolia |
For SLOT or custom chains, use --rpc-url with your Katana endpoint.
How to Specify Network
- Session auth: Use
--chain-id SN_MAINor--chain-id SN_SEPOLIA(simplest) - Execute/call/transaction: Use
--chain-idor--rpc-url(explicit)
When Network is Ambiguous
1. Run controller session status --json to check the current session's chain_id 2. Use the same network, or ask the user
Priority Order
1. --chain-id or --rpc-url flag (highest) 2. Explicit config/env (config set rpc-url or CARTRIDGE_RPC_URL) 3. Stored session RPC URL (from authorization) 4. Default (SN_SEPOLIA)
---
Paymaster Control
By default, transactions use the paymaster (free execution). If the paymaster is unavailable, the transaction fails rather than falling back to user-funded execution.
Use --no-paymaster to bypass the paymaster and pay with user funds:
controller execute \
0x... \
transfer \
0x... \
--no-paymaster \
--json| Scenario | Flag | Behavior |
|---|---|---|
| Default | None | Free via paymaster, fails if unavailable |
| Urgent / self-pay | --no-paymaster | User pays fees directly |
---
Error Handling
All errors return JSON:
{
"status": "error",
"error_code": "ErrorType",
"message": "Human-readable description",
"recovery_hint": "Suggested action"
}| Error Code | Cause | Recovery |
|---|---|---|
NoSession | No keypair found | Run controller session auth --file policy.json --json |
SessionExpired | Session past expiry | Run controller session auth again |
ManualExecutionRequired | No authorized session for this transaction | Authorize session with appropriate policies |
CallbackTimeout | User didn't authorize within 360s | Retry session auth, ask user to authorize faster |
InvalidInput (UnsupportedChainId) | Bad chain ID | Use SN_MAIN or SN_SEPOLIA, or --rpc-url for custom chains |
InvalidInput (PresetNotFound) | Unknown preset name | Check available presets |
InvalidInput (PresetChainNotSupported) | Preset doesn't support requested chain | Use a supported chain or create a custom policy file |
---
Use Cases
The lookup + execute commands combine to enable natural-language workflows. An LLM can resolve usernames to addresses transparently, then build the right transaction.
Send tokens to a username
"Send 1 STRK to broody"
1. controller lookup --usernames broody --json → resolves to broody:0xABC... 2. controller execute 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d transfer 0xABC...,u256:1000000000000000000 --json
Interact with a game using a player's username
"Attack loaf's realm at grid 1,2"
1. controller lookup --usernames loaf --json → resolves to loaf:0xDEF... 2. controller execute 0xGAME_CONTRACT attack 0xDEF...,0x1,0x2 --json
Check who owns an address
"Who is 0x123...?"
1. controller lookup --addresses 0x123... --json → resolves to shinobi:0x123...
Check account balance
"How much ETH do I have?"
1. controller balance eth --json → returns balance with formatted and raw values
---
Best Practices
1. Always use `--json` flag for machine-readable output 2. Always be explicit about network - use --chain-id or --rpc-url 3. Check session status before executing to verify session exists and isn't expired 4. Prefer presets for known games/apps - they're maintained by project teams 5. Display authorization URLs clearly and explain the human authorization step 6. Handle errors by checking error_code and following recovery_hint 7. Validate addresses (must be hex with 0x prefix) 8. Always use Voyager for transaction links, never Starkscan 9. Use `u256:` prefix for token amounts instead of manual low/high splitting 10. Use `balance` command instead of raw call balance_of for token balance queries
---
Security
- Private keys stored locally in
~/.config/controller-cli/with restricted permissions - Sessions are scoped: only authorized contracts, methods, and time window
- Human browser authorization required for all sessions (cannot be automated)
- Expired sessions are automatically rejected
---
Support
- Repository: https://github.com/cartridge-gg/controller-cli
- Issues: https://github.com/cartridge-gg/controller-cli/issues
- Skill: .claude/skills/controller-skill
.PHONY: help install build release clean test lint fmt check
# Default target
help:
@echo "Cartridge Controller CLI - Make targets:"
@echo ""
@echo " install - Install the CLI binary to ~/.local/bin"
@echo " build - Build debug binary"
@echo " release - Build optimized release binary"
@echo " clean - Remove build artifacts"
@echo " test - Run tests"
@echo " lint - Run clippy linter"
@echo " fmt - Format code"
@echo " check - Run all checks (fmt + lint + test)"
@echo ""
# Install to ~/.local/bin
install: release
@mkdir -p ~/.local/bin
@cp target/release/controller ~/.local/bin/
@echo "✅ Installed to ~/.local/bin/controller"
@echo ""
@echo "Make sure ~/.local/bin is in your PATH:"
@echo " export PATH=\"\$$PATH:~/.local/bin\""
# Build debug binary
build:
cargo build
# Build release binary
release:
cargo build --release
# Clean build artifacts
clean:
cargo clean
# Run tests
test:
cargo test
# Run clippy
lint:
cargo clippy -- -D warnings
# Format code
fmt:
cargo fmt
# Run all checks
check: fmt lint test
@echo "✅ All checks passed"
# Run the CLI
run:
cargo run --
# Generate keypair (example)
example-keygen:
cargo run -- generate
# Check status (example)
example-status:
cargo run -- status --json
Cartridge Controller CLI
Command-line interface for managing Cartridge Controller sessions on Starknet.
Overview
Enables automated Starknet transaction execution through a human-in-the-loop workflow:
1. Authorize a session — Generates keypair, creates authorization URL, human approves in browser, CLI auto-retrieves credentials 2. Execute transactions — Autonomously executes within authorized policies
The human operator maintains full control by authorizing specific contracts and methods through the browser.
For LLMs/AI Agents: See LLM_USAGE.md for a complete integration guide.
Installation
Quick Install (Recommended)
curl -fsSL https://raw.githubusercontent.com/cartridge-gg/controller-cli/main/install.sh | bashDownloads the appropriate binary for your platform (Linux/macOS, x86_64/ARM64) and installs to ~/.local/bin.
From Source
cargo install --git https://github.com/cartridge-gg/controller-cliUsage
1. Authorize a Session
controller session auth --file policies.json --chain-id SN_MAINOr use a preset for popular games/apps:
controller session auth --preset loot-survivor --chain-id SN_MAINUse --expires to set session duration (default: 7days). Accepts: 1min, 1hr, 1day, 7days, 1week, 1year.
This generates a new keypair, creates an authorization URL, and automatically polls until you authorize in the browser and stores the session.
2. Execute Transactions
Single call (positional args):
controller execute \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
transfer \
0xrecipient,u256:1000000000000000000Multiple calls from file:
controller execute --file examples/calls.jsonWait for confirmation:
controller execute --file calls.json --wait --timeout 300Transactions are auto-subsidized via paymaster when possible. Use --no-paymaster to pay with user funds directly.
3. Read-Only Calls
controller call \
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 \
balance_of \
0xaddressUse --block-id to query at a specific block (latest, pending, a block number, or block hash).
Calldata Formats
Calldata values support multiple formats:
| Format | Example | Description |
|---|---|---|
| Hex | 0x64 | Standard hex felt |
| Decimal | 100 | Decimal felt |
u256: | u256:1000000000000000000 | Auto-splits into low/high 128-bit felts |
str: | str:hello | Cairo short string |
The u256: prefix eliminates the need to manually split token amounts into low/high parts.
4. Get Transaction Status
controller transaction 0xTRANSACTION_HASH --chain-id SN_SEPOLIAAdd --wait to poll until the transaction is confirmed.
5. Get Transaction Receipt
controller receipt 0xTRANSACTION_HASH --chain-id SN_SEPOLIAReturns the full receipt including execution status, fee, events, and messages. Add --wait to poll until available.
6. Check Balances
# Query all token balances for the active session account
controller balance
# Query a specific token
controller balance ethQueries ERC20 balances for the active session account. Built-in tokens: ETH, STRK, USDC, USD.e, LORDS, SURVIVOR, WBTC. Custom tokens can be added via config set token.<SYMBOL> <address>.
7. Look Up Usernames / Addresses
# Resolve usernames to addresses
controller lookup --usernames shinobi,sensei
# Resolve addresses to usernames
controller lookup --addresses 0x123...,0x456...Returns username:address pairs. See the Cartridge Usernames docs for API details.
8. Get Account Username
controller usernameDisplays the Cartridge username associated with the active session account.
9. Session Management
# Check session status (no_session, keypair_only, or active with expiration)
controller session status
# List all active sessions with pagination
controller session list
controller session list --limit 20 --page 2
# Clear all stored session data
controller session clear10. Configuration
# Set a config value
controller config set rpc-url https://api.cartridge.gg/x/starknet/mainnet
# Get a config value
controller config get rpc-url
# List all config values
controller config list
# Add a custom token for balance tracking
controller config set token.MYTOKEN 0x123...Valid keys: rpc-url, keychain-url, api-url, storage-path, json-output, colors, callback-timeout, token.<symbol>.
Session Policies
Policies define which contracts and methods the session can access:
{
"contracts": {
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "STRK Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer STRK tokens"
}
]
}
}
}Available presets: loot-survivor, influence, realms, pistols, dope-wars, and more.
JSON Output
All commands support --json for machine-readable output:
controller session status --json{
"data": {
"status": "active",
"session": {
"address": "0x...",
"expires_at": 1735689600,
"expires_in_seconds": 3600,
"is_expired": false
},
"keypair": { "public_key": "0x...", "has_private_key": true }
},
"status": "success"
}Errors include error_code, message, and recovery_hint for programmatic handling.
Configuration
Config File
~/.config/controller-cli/config.toml:
[session]
storage_path = "~/.config/controller-cli"
rpc_url = "https://api.cartridge.gg/x/starknet/sepolia"
keychain_url = "https://x.cartridge.gg"
api_url = "https://api.cartridge.gg/query"
[cli]
json_output = false
use_colors = true
callback_timeout_seconds = 300
[tokens]
MYTOKEN = "0x123..."Environment Variables
| Variable | Description |
|---|---|
CARTRIDGE_STORAGE_PATH | Override storage location |
CARTRIDGE_RPC_URL | Default RPC endpoint |
CARTRIDGE_JSON_OUTPUT | Default to JSON output |
11. Starterpacks
Query and purchase starterpacks (bundled game assets).
Get info:
controller starterpack info <ID> --chain-id SN_MAINGet a price quote:
controller starterpack quote <ID> --chain-id SN_MAINPurchase via UI (default):
controller starterpack purchase <ID> --chain-id SN_MAIN
# or explicitly:
controller starterpack purchase <ID> --ui --chain-id SN_MAINOpens the Cartridge purchase page in your browser. Supports crosschain payments and Apple Pay.
Purchase directly from Controller wallet:
controller starterpack purchase <ID> --direct --chain-id SN_MAIN --jsonExecutes the purchase on-chain using the active session. Requires session policies that include approve on the payment token and issue on the starterpack contract.
Additional flags for --direct mode:
--recipient <ADDRESS>— Send to a different address (defaults to current controller)--quantity <N>— Number to purchase (default: 1)--wait— Wait for transaction confirmation--timeout <SECONDS>— Confirmation timeout (default: 300)--no-paymaster— Pay gas with user funds instead of paymaster
Architecture
Built on `account_sdk` which provides session management, transaction execution, policy validation, and file-based storage. The CLI is a thin wrapper optimized for automation and scripting.
Security
- Scoped sessions — Limited to authorized contracts, methods, and time window (typically 7 days)
- Human authorization required — Every session must be approved via browser
- Local key storage — Private keys stored in
~/.config/controller-cli/with restricted permissions - No credential logging — Sensitive data never written to logs
License
MIT
Policy File Examples
ETH Token Policy
{
"contracts": {
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "ETH Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer ETH tokens to another address"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve another address to spend ETH tokens"
}
]
}
}
}STRK Token Policy
{
"contracts": {
"0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": {
"name": "STRK Token",
"methods": [
{
"name": "transfer",
"entrypoint": "transfer",
"description": "Transfer STRK tokens to another address"
},
{
"name": "approve",
"entrypoint": "approve",
"description": "Approve another address to spend STRK tokens"
}
]
}
}
}Multi-Token Policy
Combine multiple contracts in a single policy file:
{
"contracts": {
"0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d": {
"name": "STRK Token",
"methods": [
{ "name": "transfer", "entrypoint": "transfer", "description": "Transfer STRK tokens" },
{ "name": "approve", "entrypoint": "approve", "description": "Approve STRK token spending" }
]
},
"0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7": {
"name": "ETH Token",
"methods": [
{ "name": "transfer", "entrypoint": "transfer", "description": "Transfer ETH tokens" },
{ "name": "approve", "entrypoint": "approve", "description": "Approve ETH token spending" }
]
}
}
}Custom Game Contract Policy
{
"contracts": {
"0xYOUR_GAME_CONTRACT_ADDRESS": {
"name": "My Game",
"methods": [
{ "name": "move", "entrypoint": "move", "description": "Make a move in the game" },
{ "name": "attack", "entrypoint": "attack", "description": "Attack another player" },
{ "name": "claim", "entrypoint": "claim_rewards", "description": "Claim game rewards" }
]
}
}
}use crate::error::{CliError, Result};
use serde::{Deserialize, Serialize};
use starknet::core::types::Felt;
/// Shorten a URL via the Cartridge URL shortener service.
///
/// POSTs to `{api_base}/s` and returns the short URL on success.
/// Returns `Err` on any failure so the caller can fall back to the original URL.
pub async fn shorten_url(api_url: &str, long_url: &str) -> Result<String> {
// Derive base URL by stripping `/query` from the API URL
let api_base = api_url.trim_end_matches("/query").trim_end_matches('/');
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| CliError::ApiError(format!("Failed to build HTTP client: {e}")))?;
#[derive(Serialize)]
struct ShortenRequest<'a> {
url: &'a str,
}
#[derive(Deserialize)]
struct ShortenResponse {
url: String,
}
let response = client
.post(format!("{api_base}/s"))
.json(&ShortenRequest { url: long_url })
.send()
.await
.map_err(|e| CliError::ApiError(format!("Failed to shorten URL: {e}")))?;
if !response.status().is_success() {
return Err(CliError::ApiError(format!(
"URL shortener returned error status: {}",
response.status()
)));
}
let shorten_response: ShortenResponse = response
.json()
.await
.map_err(|e| CliError::ApiError(format!("Failed to parse shortener response: {e}")))?;
Ok(shorten_response.url)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionInfo {
pub authorization: Vec<String>, // Hex-encoded Felt values
pub controller: ControllerInfo,
#[serde(rename = "chainID")]
pub chain_id: String,
#[serde(rename = "expiresAt")]
pub expires_at: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ControllerInfo {
pub address: String,
#[serde(rename = "accountID")]
pub account_id: String,
}
/// Query session creation from the Cartridge API (long-polling)
///
/// This uses the `subscribeCreateSession` query which implements long-polling:
/// - Backend holds the HTTP connection open for up to 2 minutes
/// - Checks database periodically for session creation
/// - Returns null if timeout, or SessionInfo if session is created
///
/// Despite the name, this is a **Query** not a Subscription.
pub async fn query_session_info(
api_url: &str,
session_key_guid: &str,
) -> Result<Option<SessionInfo>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(130)) // Slightly longer than backend's 2min timeout
.build()
.map_err(|e| CliError::ApiError(format!("Failed to build HTTP client: {e}")))?;
// This is a QUERY (not subscription) despite the name
let query = r#"
query SubscribeCreateSession($sessionKeyGuid: Felt!) {
subscribeCreateSession(sessionKeyGuid: $sessionKeyGuid) {
id
appID
chainID
isRevoked
expiresAt
createdAt
updatedAt
authorization
controller {
address
accountID
}
}
}
"#;
#[derive(Serialize)]
struct Variables {
#[serde(rename = "sessionKeyGuid")]
session_key_guid: String,
}
#[derive(Serialize)]
struct GraphQLRequest {
query: String,
variables: Variables,
}
#[derive(Deserialize)]
struct GraphQLResponse {
data: Option<GraphQLData>,
errors: Option<Vec<GraphQLError>>,
}
#[derive(Deserialize)]
struct GraphQLData {
#[serde(rename = "subscribeCreateSession")]
subscribe_create_session: Option<SessionInfo>,
}
#[derive(Deserialize)]
struct GraphQLError {
message: String,
}
let request = GraphQLRequest {
query: query.to_string(),
variables: Variables {
session_key_guid: session_key_guid.to_string(),
},
};
let response = client
.post(api_url)
.json(&request)
.send()
.await
.map_err(|e| CliError::ApiError(format!("Failed to query session info: {e}")))?;
if !response.status().is_success() {
return Err(CliError::ApiError(format!(
"API returned error status: {}",
response.status()
)));
}
let graphql_response: GraphQLResponse = response
.json()
.await
.map_err(|e| CliError::ApiError(format!("Failed to parse API response: {e}")))?;
if let Some(errors) = graphql_response.errors {
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
return Err(CliError::ApiError(format!(
"GraphQL errors: {}",
error_messages.join(", ")
)));
}
Ok(graphql_response
.data
.and_then(|data| data.subscribe_create_session))
}
impl SessionInfo {
/// Convert authorization strings to Felt values
pub fn authorization_as_felts(&self) -> Result<Vec<Felt>> {
self.authorization
.iter()
.map(|hex| {
Felt::from_hex(hex).map_err(|e| {
CliError::InvalidSessionData(format!("Invalid authorization hex: {e}"))
})
})
.collect()
}
/// Convert address string to Felt
pub fn address_as_felt(&self) -> Result<Felt> {
Felt::from_hex(&self.controller.address)
.map_err(|e| CliError::InvalidSessionData(format!("Invalid address hex: {e}")))
}
/// Convert chain_id string to Felt
pub fn chain_id_as_felt(&self) -> Result<Felt> {
// Try hex first
if let Ok(felt) = Felt::from_hex(&self.chain_id) {
return Ok(felt);
}
// Try as short string (e.g., "SN_SEPOLIA")
if let Ok(felt) = starknet::core::utils::cairo_short_string_to_felt(&self.chain_id) {
return Ok(felt);
}
// Debug: show what we got
Err(CliError::InvalidSessionData(format!(
"Invalid chain_id format: '{}' (expected hex or short string)",
self.chain_id
)))
}
}
use crate::config::Config;
use crate::error::{CliError, Result};
use crate::output::OutputFormatter;
use account_sdk::storage::{filestorage::FileSystemBackend, StorageBackend};
use serde::{Deserialize, Serialize};
use starknet::core::types::{BlockId, BlockTag, Felt, FunctionCall};
use starknet::providers::{jsonrpc::HttpTransport, JsonRpcClient, Provider};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
const CACHE_TTL_SECS: u64 = 30;
struct TokenInfo {
address: &'static str,
decimals: u8,
}
fn builtin_tokens() -> Vec<(&'static str, TokenInfo)> {
vec![
(
"ETH",
TokenInfo {
address: "0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7",
decimals: 18,
},
),
(
"STRK",
TokenInfo {
address: "0x04718f5a0Fc34cC1AF16A1cdee98fFB20C31f5cD61D6Ab07201858f4287c938D",
decimals: 18,
},
),
(
"USDC",
TokenInfo {
address: "0x033068F6539f8e6e6b131e6B2B814e6c34A5224bC66947c47DaB9dFeE93b35fb",
decimals: 6,
},
),
(
"USD.e",
TokenInfo {
address: "0x053C91253BC9682c04929cA02ED00b3E423f6710D2ee7e0D5EBB06F3eCF368A8",
decimals: 6,
},
),
(
"LORDS",
TokenInfo {
address: "0x0124aeb495b947201f5faC96fD1138E326AD86195B98df6DEc9009158A533B49",
decimals: 18,
},
),
(
"SURVIVOR",
TokenInfo {
address: "0x042DD777885AD2C116be96d4D634abC90A26A790ffB5871E037Dd5Ae7d2Ec86B",
decimals: 18,
},
),
(
"WBTC",
TokenInfo {
address: "0x03Fe2b97C1Fd336E750087D68B9b867997Fd64a2661fF3ca5A7C771641e8e7AC",
decimals: 8,
},
),
]
}
/// Query a single token's balance and decimals
async fn query_token_balance(
provider: Arc<JsonRpcClient<HttpTransport>>,
sym: String,
contract_address: Felt,
account_address: Felt,
known_decimals: Option<u8>,
) -> std::result::Result<BalanceOutput, String> {
let balance_of_selector = starknet::core::utils::get_selector_from_name("balance_of").unwrap();
let balance_call = FunctionCall {
contract_address,
entry_point_selector: balance_of_selector,
calldata: vec![account_address],
};
let balance_result = provider
.call(balance_call, BlockId::Tag(BlockTag::Latest))
.await
.map_err(|e| format!("Skipping {sym}: balance_of failed: {e}"))?;
let (raw_low, raw_high) = match balance_result.len() {
1 => (balance_result[0], Felt::ZERO),
2.. => (balance_result[0], balance_result[1]),
_ => return Err(format!("Skipping {sym}: unexpected balance_of response")),
};
let decimals = match known_decimals {
Some(d) => d,
None => {
let decimals_selector =
starknet::core::utils::get_selector_from_name("decimals").unwrap();
let decimals_call = FunctionCall {
contract_address,
entry_point_selector: decimals_selector,
calldata: vec![],
};
match provider
.call(decimals_call, BlockId::Tag(BlockTag::Latest))
.await
{
Ok(r) if !r.is_empty() => {
let val: u64 = r[0].try_into().unwrap_or(18);
val as u8
}
_ => 18,
}
}
};
let formatted = format_u256_balance(raw_low, raw_high, decimals);
let raw_hex = if raw_high == Felt::ZERO {
format!("0x{raw_low:x}")
} else {
format!("0x{raw_high:x}{:032x}", felt_to_u128(raw_low))
};
Ok(BalanceOutput {
token: sym,
balance: formatted,
raw: raw_hex,
contract: format!("0x{contract_address:x}"),
})
}
/// Query ERC20 token balances for the active session account
pub async fn execute(
config: &Config,
formatter: &dyn OutputFormatter,
symbol: Option<String>,
chain_id: Option<String>,
rpc_url: Option<String>,
account: Option<&str>,
) -> Result<()> {
// Load session to get account address
let storage_path = config.resolve_storage_path(account);
let backend = FileSystemBackend::new(storage_path.clone());
let controller = backend
.controller()
.ok()
.flatten()
.ok_or(CliError::NoSession)?;
let account_address = controller.address;
// Resolve RPC URL
let rpc_url = resolve_rpc_url(chain_id, rpc_url, config, formatter)?;
// Check cache
let cache_key = format!("0x{account_address:x}");
if let Some(cached) = load_cache(&storage_path, &cache_key) {
let results = filter_results(cached, &symbol);
return output_results(config, formatter, &results);
}
let url = url::Url::parse(&rpc_url)
.map_err(|e| CliError::InvalidInput(format!("Invalid RPC URL: {e}")))?;
let provider = Arc::new(JsonRpcClient::new(HttpTransport::new(url)));
// Build token list: built-in defaults + config overrides
let mut tokens: BTreeMap<String, String> = BTreeMap::new();
for (sym, info) in builtin_tokens() {
tokens.insert(sym.to_string(), info.address.to_string());
}
for (sym, addr) in &config.tokens {
tokens.insert(sym.clone(), addr.clone());
}
// Spawn all balance queries concurrently
let mut handles = Vec::new();
let token_order: Vec<String> = tokens.keys().cloned().collect();
for (sym, addr_str) in &tokens {
let contract_address = match Felt::from_hex(addr_str) {
Ok(a) => a,
Err(e) => {
formatter.warning(&format!("Skipping {sym}: invalid address: {e}"));
continue;
}
};
let known_decimals = builtin_tokens()
.iter()
.find(|(s, _)| s.to_uppercase() == sym.to_uppercase())
.map(|(_, info)| info.decimals);
let provider = Arc::clone(&provider);
let sym = sym.clone();
handles.push(tokio::spawn(query_token_balance(
provider,
sym,
contract_address,
account_address,
known_decimals,
)));
}
// Collect results, preserving token order
let query_results = futures::future::join_all(handles).await;
let mut result_map: BTreeMap<String, BalanceOutput> = BTreeMap::new();
for res in query_results {
match res {
Ok(Ok(output)) => {
result_map.insert(output.token.clone(), output);
}
Ok(Err(warning)) => {
formatter.warning(&warning);
}
Err(e) => {
formatter.warning(&format!("Task failed: {e}"));
}
}
}
let all_results: Vec<BalanceOutput> = token_order
.iter()
.filter_map(|sym| result_map.remove(sym))
.collect();
// Save to cache (all tokens, before filtering)
save_cache(&storage_path, &cache_key, &all_results);
let results = filter_results(all_results, &symbol);
output_results(config, formatter, &results)
}
/// Filter results: by symbol if specified, and skip zero balances when querying all
fn filter_results(results: Vec<BalanceOutput>, symbol: &Option<String>) -> Vec<BalanceOutput> {
results
.into_iter()
.filter(|r| {
if let Some(ref sym) = symbol {
r.token.to_uppercase() == sym.to_uppercase()
} else {
// Skip zero balances when querying all tokens
r.raw != "0x0"
}
})
.collect()
}
fn output_results(
config: &Config,
formatter: &dyn OutputFormatter,
results: &[BalanceOutput],
) -> Result<()> {
if config.cli.json_output {
formatter.success(&results);
} else {
for r in results {
println!("{} {}", r.balance, r.token);
}
}
Ok(())
}
// --- Cache ---
#[derive(Serialize, Deserialize)]
struct BalanceCache {
timestamp: u64,
balances: Vec<BalanceOutput>,
}
fn cache_path(storage_path: &std::path::Path, account: &str) -> PathBuf {
storage_path.join(format!("balance_cache_{account}.json"))
}
fn load_cache(storage_path: &std::path::Path, account: &str) -> Option<Vec<BalanceOutput>> {
let path = cache_path(storage_path, account);
let content = std::fs::read_to_string(&path).ok()?;
let cache: BalanceCache = serde_json::from_str(&content).ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
if now - cache.timestamp <= CACHE_TTL_SECS {
Some(cache.balances)
} else {
// Expired — clean up
let _ = std::fs::remove_file(&path);
None
}
}
fn save_cache(storage_path: &std::path::Path, account: &str, balances: &[BalanceOutput]) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cache = BalanceCache {
timestamp: now,
balances: balances.to_vec(),
};
if let Ok(json) = serde_json::to_string(&cache) {
let _ = std::fs::write(cache_path(storage_path, account), json);
}
}
// --- Formatting ---
fn felt_to_u128(f: Felt) -> u128 {
let bytes = f.to_bytes_be();
u128::from_be_bytes(bytes[16..32].try_into().unwrap())
}
/// Format a u256 balance (given as low/high felt pair) with decimal places.
/// Shows up to 6 decimal places.
fn format_u256_balance(low: Felt, high: Felt, decimals: u8) -> String {
let low_val = felt_to_u128(low);
let high_val = felt_to_u128(high);
if decimals == 0 {
if high_val == 0 {
return low_val.to_string();
}
return format!("0x{high_val:x}{low_val:032x}");
}
if high_val == 0 {
return format_u128_balance(low_val, decimals);
}
let combined = format!("{high_val:032x}{low_val:032x}");
format!("0x{combined}")
}
/// Format a u128 balance with the given number of decimals (up to 6 visible decimal places)
fn format_u128_balance(value: u128, decimals: u8) -> String {
if decimals == 0 {
return value.to_string();
}
let display_decimals = std::cmp::min(decimals as usize, 6);
let divisor = 10u128.pow(decimals as u32);
let whole = value / divisor;
let remainder = value % divisor;
let padded = format!("{:0>width$}", remainder, width = decimals as usize);
let truncated = &padded[..display_decimals];
format!("{whole}.{truncated}")
}
/// Resolve RPC URL from chain_id, explicit rpc_url, or config
fn resolve_rpc_url(
chain_id: Option<String>,
rpc_url: Option<String>,
config: &Config,
formatter: &dyn OutputFormatter,
) -> Result<String> {
if let Some(url) = rpc_url {
return Ok(url);
}
if let Some(chain) = chain_id {
match chain.as_str() {
"SN_MAIN" => Ok("https://api.cartridge.gg/x/starknet/mainnet".to_string()),
"SN_SEPOLIA" => Ok("https://api.cartridge.gg/x/starknet/sepolia".to_string()),
_ => Err(CliError::InvalidInput(format!(
"Unsupported chain ID '{chain}'. Supported chains: SN_MAIN, SN_SEPOLIA"
))),
}
} else if !config.session.rpc_url.is_empty() {
Ok(config.session.rpc_url.clone())
} else {
formatter.warning("No --chain-id or --rpc-url specified, using SN_SEPOLIA by default");
Ok("https://api.cartridge.gg/x/starknet/sepolia".to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BalanceOutput {
token: String,
balance: String,
raw: String,
contract: String,
}
use crate::{config::Config, error::Result, output::OutputFormatter};
use account_sdk::storage::{filestorage::FileSystemBackend, StorageBackend};
use serde::Serialize;
#[derive(Serialize)]
pub struct ClearOutput {
pub message: String,
pub cleared_path: String,
}
pub async fn execute(
config: &Config,
formatter: &dyn OutputFormatter,
skip_confirm: bool,
account: Option<&str>,
) -> Result<()> {
let storage_path = config.resolve_storage_path(account);
let mut backend = FileSystemBackend::new(storage_path.clone());
if !skip_confirm && !config.cli.json_output {
// In human mode, ask for confirmation
println!(
"This will delete all stored session data at: {}",
storage_path.display()
);
println!("Are you sure? (y/N): ");
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
if !input.trim().eq_ignore_ascii_case("y") && !input.trim().eq_ignore_ascii_case("yes") {
formatter.info("Cancelled.");
return Ok(());
}
}
backend
.clear()
.map_err(|e| crate::error::CliError::Storage(e.to_string()))?;
let output = ClearOutput {
message: "All session data cleared successfully.".to_string(),
cleared_path: storage_path.display().to_string(),
};
formatter.success(&output);
Ok(())
}
use crate::config::Config;
use crate::output::OutputFormatter;
use serde::Serialize;
#[derive(Serialize)]
struct ConfigEntry {
key: String,
value: String,
}
#[derive(Serialize)]
struct ConfigList {
entries: Vec<ConfigEntry>,
}
pub async fn execute_set(
formatter: &dyn OutputFormatter,
key: String,
value: String,
) -> Result<(), crate::error::CliError> {
// Load config from file only (no env merge) so we persist file-level values
let mut config = Config::load().map_err(|e| crate::error::CliError::Config(e.to_string()))?;
config
.set_by_alias(&key, &value)
.map_err(|e| crate::error::CliError::Config(e.to_string()))?;
config
.save()
.map_err(|e| crate::error::CliError::Config(e.to_string()))?;
formatter.info(&format!("Set {key} = {value}"));
Ok(())
}
pub async fn execute_get(
formatter: &dyn OutputFormatter,
json_output: bool,
key: String,
) -> Result<(), crate::error::CliError> {
// Show effective value (file + env merged)
let mut config = Config::load().map_err(|e| crate::error::CliError::Config(e.to_string()))?;
config.merge_from_env();
let value = config
.get_by_alias(&key)
.map_err(|e| crate::error::CliError::Config(e.to_string()))?;
if json_output {
let entry = ConfigEntry {
key: key.clone(),
value,
};
formatter.success(&entry);
} else {
println!("{value}");
}
Ok(())
}
pub async fn execute_list(
formatter: &dyn OutputFormatter,
json_output: bool,
) -> Result<(), crate::error::CliError> {
// Show effective values (file + env merged)
let mut config = Config::load().map_err(|e| crate::error::CliError::Config(e.to_string()))?;
config.merge_from_env();
let entries: Vec<ConfigEntry> = Config::VALID_KEYS
.iter()
.map(|&key| {
let value = config
.get_by_alias(key)
.unwrap_or_else(|_| "<error>".to_string());
ConfigEntry {
key: key.to_string(),
value,
}
})
.collect();
if json_output {
let list = ConfigList { entries };
formatter.success(&list);
} else {
let max_key_len = entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
for entry in &entries {
println!(
"{:<width$} {}",
entry.key,
entry.value,
width = max_key_len
);
}
}
Ok(())
}
pub mod balance;
pub mod call;
pub mod calldata;
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;
pub mod status;
pub mod transaction;
pub mod username;
pub mod authorize;
pub mod list;
pub mod revoke;
use crate::{config::Config, error::Result, output::OutputFormatter};
pub async fn execute(
_config: &Config,
formatter: &dyn OutputFormatter,
_account: Option<&str>,
) -> Result<()> {
formatter.info("Not yet implemented");
Ok(())
}
mod human;
mod json;
pub use human::HumanFormatter;
pub use json::JsonFormatter;
use crate::error::CliError;
pub trait OutputFormatter {
fn success(&self, data: &dyn erased_serde::Serialize);
fn error(&self, error: &CliError);
fn info(&self, message: &str);
fn warning(&self, message: &str);
}
pub fn create_formatter(use_json: bool, use_colors: bool) -> Box<dyn OutputFormatter> {
if use_json {
Box::new(JsonFormatter)
} else {
Box::new(HumanFormatter::new(use_colors))
}
}