├── rust-toolchain.toml ├── scripts ├── rust_fmt_fix.sh ├── rust_fmt.sh ├── clippy.sh ├── pull_schema.sh ├── test-capacity.sh └── e2e.sh ├── .github ├── cover.png ├── dependabot.yml └── workflows │ ├── e2e.yml │ ├── schema-sync.yml │ ├── release-dispatch.yml │ ├── ci.yml │ └── release.yml ├── .graphqlrc.yml ├── .gitignore ├── slotup ├── README.md └── install ├── slot ├── src │ ├── graphql │ │ ├── team │ │ │ ├── delete.graphql │ │ │ ├── create.graphql │ │ │ ├── update.graphql │ │ │ ├── members.graphql │ │ │ ├── teams.graphql │ │ │ ├── invoices.graphql │ │ │ └── mod.rs │ │ ├── deployments │ │ │ ├── delete.graphql │ │ │ ├── transfer.graphql │ │ │ ├── accounts.graphql │ │ │ ├── list.rs │ │ │ ├── accounts.rs │ │ │ ├── create.rs │ │ │ ├── delete.rs │ │ │ ├── update.rs │ │ │ ├── describe.rs │ │ │ ├── transfer.rs │ │ │ ├── describe.graphql │ │ │ ├── logs.rs │ │ │ ├── logs.graphql │ │ │ ├── mod.rs │ │ │ ├── list.graphql │ │ │ ├── update.graphql │ │ │ └── create.graphql │ │ ├── auth │ │ │ ├── update-me.graphql │ │ │ ├── transfer.graphql │ │ │ ├── info.graphql │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── rpc │ │ │ ├── delete_token.graphql │ │ │ ├── remove_whitelist_origin.graphql │ │ │ ├── create_token.graphql │ │ │ ├── add_whitelist_origin.graphql │ │ │ ├── list_whitelist_origins.graphql │ │ │ ├── list_tokens.graphql │ │ │ ├── logs.graphql │ │ │ └── mod.rs │ │ ├── paymaster │ │ │ ├── remove_all_policies.graphql │ │ │ ├── remove_policy.graphql │ │ │ ├── update.graphql │ │ │ ├── stats.graphql │ │ │ ├── create.graphql │ │ │ ├── decrease_budget.graphql │ │ │ ├── increase_budget.graphql │ │ │ ├── add_policies.graphql │ │ │ ├── info.graphql │ │ │ ├── list_policies.graphql │ │ │ ├── transaction.graphql │ │ │ ├── list_paymasters.graphql │ │ │ └── mod.rs │ │ └── merkle_drop │ │ │ ├── mod.rs │ │ │ └── create.graphql │ ├── lib.rs │ ├── vars.rs │ ├── browser.rs │ ├── account.rs │ ├── read.rs │ ├── bigint.rs │ ├── error.rs │ ├── server.rs │ ├── utils.rs │ ├── api.rs │ └── credential.rs └── Cargo.toml ├── .githooks └── pre-commit ├── cli ├── src │ ├── command │ │ ├── auth │ │ │ ├── fund.rs │ │ │ ├── email.rs │ │ │ ├── token.rs │ │ │ ├── session.rs │ │ │ ├── mod.rs │ │ │ ├── transfer.rs │ │ │ ├── login.rs │ │ │ └── info.rs │ │ ├── paymasters │ │ │ ├── mod.rs │ │ │ └── list.rs │ │ ├── merkle_drops │ │ │ └── mod.rs │ │ ├── teams │ │ │ ├── delete.rs │ │ │ ├── create.rs │ │ │ ├── update.rs │ │ │ ├── mod.rs │ │ │ ├── members.rs │ │ │ └── invoices.rs │ │ ├── rpc │ │ │ ├── mod.rs │ │ │ ├── tokens.rs │ │ │ ├── whitelist.rs │ │ │ ├── tokens │ │ │ │ ├── delete.rs │ │ │ │ ├── create.rs │ │ │ │ └── list.rs │ │ │ ├── whitelist │ │ │ │ ├── remove.rs │ │ │ │ ├── add.rs │ │ │ │ └── list.rs │ │ │ └── logs.rs │ │ ├── deployments │ │ │ ├── services │ │ │ │ ├── mod.rs │ │ │ │ ├── torii.rs │ │ │ │ └── katana.rs │ │ │ ├── list.rs │ │ │ ├── delete.rs │ │ │ ├── transfer.rs │ │ │ ├── describe.rs │ │ │ ├── update.rs │ │ │ ├── logs.rs │ │ │ ├── mod.rs │ │ │ └── accounts.rs │ │ ├── paymaster │ │ │ ├── utils.rs │ │ │ ├── update.rs │ │ │ ├── create.rs │ │ │ ├── stats.rs │ │ │ ├── mod.rs │ │ │ ├── info.rs │ │ │ └── budget.rs │ │ └── mod.rs │ └── main.rs └── Cargo.toml ├── Cargo.toml ├── .claude └── commands │ ├── pr.md │ └── commit.md ├── Dockerfile ├── CONTRIBUTING.md ├── README.md └── CLAUDE.md /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85.0" 3 | -------------------------------------------------------------------------------- /scripts/rust_fmt_fix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cargo +nightly fmt --all -- "$@" 4 | -------------------------------------------------------------------------------- /.github/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cartridge-gg/slot/HEAD/.github/cover.png -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | schema: slot/schema.json 2 | documents: slot/src/graphql/**/*.graphql 3 | -------------------------------------------------------------------------------- /scripts/rust_fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cargo +nightly fmt --check --all -- "$@" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | torii.toml 3 | katana.toml 4 | .envrc 5 | 6 | .claude/settings.local.json 7 | -------------------------------------------------------------------------------- /slotup/README.md: -------------------------------------------------------------------------------- 1 | # `slotup` 2 | 3 | ```sh 4 | curl -L https://slot.cartridge.sh | bash 5 | ``` 6 | -------------------------------------------------------------------------------- /slot/src/graphql/team/delete.graphql: -------------------------------------------------------------------------------- 1 | mutation DeleteTeam($name: String!) { 2 | deleteTeam(name: $name) 3 | } 4 | -------------------------------------------------------------------------------- /slot/src/graphql/team/create.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateTeam($name: String!, $input: TeamInput) { 2 | createTeam(name: $name, data: $input) { 3 | id 4 | name 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /slot/src/graphql/team/update.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateTeam($name: String!, $input: TeamInput!) { 2 | updateTeam(name: $name, update: $input) { 3 | id 4 | name 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scripts/clippy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cargo clippy --all-targets --all-features "$@" -- -D warnings -D future-incompatible -D nonstandard-style -D rust-2018-idioms -D unused 4 | 5 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/delete.graphql: -------------------------------------------------------------------------------- 1 | mutation DeleteDeployment($project: String!, $service: DeploymentService!) { 2 | deleteDeployment(name: $project, service: $service) 3 | } 4 | -------------------------------------------------------------------------------- /slot/src/graphql/auth/update-me.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateMe( 2 | $email: String 3 | ) { 4 | updateMe(data: { 5 | email: $email 6 | }) { 7 | username 8 | email 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /slot/src/graphql/auth/transfer.graphql: -------------------------------------------------------------------------------- 1 | mutation Transfer($transfer: TransferInput!) { 2 | transfer(data: $transfer) { 3 | accountBefore 4 | accountAfter 5 | teamBefore 6 | teamAfter 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /slot/src/graphql/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod deployments; 3 | pub mod merkle_drop; 4 | pub mod paymaster; 5 | pub mod rpc; 6 | pub mod team; 7 | 8 | pub use graphql_client::{GraphQLQuery, Response}; 9 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/transfer.graphql: -------------------------------------------------------------------------------- 1 | mutation TransferDeployment($name: String!, $service: DeploymentService!, $team: String!) { 2 | transferDeployment(name: $name, service: $service, team: $team) 3 | } 4 | -------------------------------------------------------------------------------- /slot/src/graphql/rpc/delete_token.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to delete an RPC API key 2 | # Operation name `DeleteRpcApiKey` must match the struct name in Rust 3 | mutation DeleteRpcApiKey($id: ID!) { 4 | deleteRpcApiKey(id: $id) 5 | } -------------------------------------------------------------------------------- /slot/src/graphql/rpc/remove_whitelist_origin.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to delete RPC CORS domain 2 | # Operation name `DeleteRpcCorsDomain` must match the struct name in Rust 3 | mutation DeleteRpcCorsDomain($id: ID!) { 4 | deleteRpcCorsDomain(id: $id) 5 | } -------------------------------------------------------------------------------- /slot/src/graphql/deployments/accounts.graphql: -------------------------------------------------------------------------------- 1 | query KatanaAccounts($project: String!) { 2 | deployment(name: $project, service: katana) { 3 | project 4 | branch 5 | tier 6 | version 7 | config { 8 | configFile 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/remove_all_policies.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to remove all policies from a paymaster 2 | # Operation name `RemoveAllPolicies` must match the struct name in Rust 3 | mutation RemoveAllPolicies($paymasterName: ID!) { 4 | removeAllPolicies(paymasterName: $paymasterName) 5 | } -------------------------------------------------------------------------------- /slot/src/graphql/deployments/list.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/list.graphql" 8 | )] 9 | pub struct ListDeployments; 10 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/accounts.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/accounts.graphql" 8 | )] 9 | pub struct KatanaAccounts; 10 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/create.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/create.graphql" 8 | )] 9 | pub struct CreateDeployment; 10 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/delete.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/delete.graphql" 8 | )] 9 | pub struct DeleteDeployment; 10 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/update.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/update.graphql" 8 | )] 9 | pub struct UpdateDeployment; 10 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/remove_policy.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to remove policies from a paymaster 2 | # Operation name `RemovePolicy` must match the struct name in Rust 3 | mutation RemovePolicy($paymasterName: ID!, $policy: PolicyInput!) { 4 | removePolicy(paymasterName: $paymasterName, policy: $policy) 5 | } -------------------------------------------------------------------------------- /slot/src/graphql/deployments/describe.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/describe.graphql" 8 | )] 9 | pub struct DescribeDeployment; 10 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/transfer.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | #[derive(GraphQLQuery)] 4 | #[graphql( 5 | response_derives = "Debug", 6 | schema_path = "schema.json", 7 | query_path = "src/graphql/deployments/transfer.graphql" 8 | )] 9 | pub struct TransferDeployment; 10 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Running pre-commit hook..." 5 | 6 | # Run clippy 7 | echo "Running cargo clippy..." 8 | ./scripts/clippy.sh 9 | 10 | # Run rustfmt 11 | echo "Running cargo +nightly fmt..." 12 | ./scripts/rust_fmt_fix.sh 13 | 14 | echo "Pre-commit hook completed successfully." 15 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/describe.graphql: -------------------------------------------------------------------------------- 1 | query DescribeDeployment($project: String!, $service: DeploymentService!) { 2 | deployment(name: $project, service: $service) { 3 | deprecated 4 | project 5 | branch 6 | tier 7 | version 8 | error 9 | config { 10 | configFile 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/logs.rs: -------------------------------------------------------------------------------- 1 | use crate::graphql::deployments::Time; 2 | use graphql_client::GraphQLQuery; 3 | 4 | #[derive(GraphQLQuery)] 5 | #[graphql( 6 | response_derives = "Debug", 7 | schema_path = "schema.json", 8 | query_path = "src/graphql/deployments/logs.graphql" 9 | )] 10 | pub struct DeploymentLogs; 11 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/update.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to update a paymaster 2 | # Operation name `UpdatePaymaster` must match the struct name in Rust 3 | mutation UpdatePaymaster($paymasterName: ID!, $newName: String, $teamName: String, $active: Boolean) { 4 | updatePaymaster(paymasterName: $paymasterName, newName: $newName, teamName: $teamName, active: $active) 5 | } -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/stats.graphql: -------------------------------------------------------------------------------- 1 | query PaymasterStats($paymasterName: ID!, $since: Time!) { 2 | paymasterStats(paymasterName: $paymasterName, since: $since) { 3 | minUsdFee 4 | maxUsdFee 5 | avgUsdFee 6 | totalUsdFees 7 | totalTransactions 8 | successfulTransactions 9 | revertedTransactions 10 | uniqueUsers 11 | } 12 | } -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/create.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to create a new paymaster 2 | # Operation name `CreatePaymaster` must match the struct name in Rust 3 | mutation CreatePaymaster($name: String!, $teamName: String!, $budget: Int!, $unit: FeeUnit!) { 4 | createPaymaster(name: $name, teamName: $teamName, budget: $budget, unit: $unit) { 5 | name 6 | budget 7 | } 8 | } -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/decrease_budget.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to decrease a paymaster's budget 2 | # Operation name `DecreaseBudget` must match the struct name in Rust 3 | mutation DecreaseBudget($paymasterName: ID!, $amount: Int!, $unit: FeeUnit!) { 4 | decreaseBudget(paymasterName: $paymasterName, amount: $amount, unit: $unit) { 5 | id 6 | name 7 | budget 8 | } 9 | } -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/increase_budget.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to increase a paymaster's budget 2 | # Operation name `IncreaseBudget` must match the struct name in Rust 3 | mutation IncreaseBudget($paymasterName: ID!, $amount: Int!, $unit: FeeUnit!) { 4 | increaseBudget(paymasterName: $paymasterName, amount: $amount, unit: $unit) { 5 | id 6 | name 7 | budget 8 | } 9 | } -------------------------------------------------------------------------------- /slot/src/graphql/deployments/logs.graphql: -------------------------------------------------------------------------------- 1 | query DeploymentLogs( 2 | $project: String! 3 | $service: DeploymentService! 4 | $since: Time 5 | $limit: Int 6 | $container: String 7 | ) { 8 | deployment(name: $project, service: $service) { 9 | logs(since: $since, limit: $limit, container: $container) { 10 | content 11 | until 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /slot/src/graphql/rpc/create_token.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to create a new RPC API key 2 | # Operation name `CreateRpcApiKey` must match the struct name in Rust 3 | mutation CreateRpcApiKey($teamName: String!, $name: String!) { 4 | createRpcApiKey(teamName: $teamName, name: $name) { 5 | apiKey { 6 | id 7 | name 8 | keyPrefix 9 | createdAt 10 | } 11 | secretKey 12 | } 13 | } -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/add_policies.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to add policies to a paymaster 2 | # Operation name `AddPolicies` must match the struct name in Rust 3 | mutation AddPolicies( 4 | $paymasterName: ID! 5 | $policies: [PolicyInput!]! 6 | ) { 7 | addPolicies(paymasterName: $paymasterName, policies: $policies) { 8 | id 9 | contractAddress 10 | entryPoint 11 | selector 12 | } 13 | } -------------------------------------------------------------------------------- /slot/src/graphql/rpc/add_whitelist_origin.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to create RPC CORS domain 2 | # Operation name `CreateRpcCorsDomain` must match the struct name in Rust 3 | mutation CreateRpcCorsDomain($teamName: String!, $domain: String!, $rateLimitPerMinute: Int) { 4 | createRpcCorsDomain(teamName: $teamName, domain: $domain, rateLimitPerMinute: $rateLimitPerMinute) { 5 | id 6 | domain 7 | createdAt 8 | } 9 | } -------------------------------------------------------------------------------- /cli/src/command/auth/fund.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::{browser, vars}; 4 | 5 | #[derive(Debug, Args)] 6 | pub struct FundArgs; 7 | 8 | impl FundArgs { 9 | pub async fn run(&self) -> Result<()> { 10 | let url = vars::get_cartridge_keychain_url(); 11 | 12 | let url = format!("{url}/slot/fund"); 13 | 14 | browser::open(&url)?; 15 | 16 | Ok(()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /slot/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(test), warn(unused_crate_dependencies))] 2 | 3 | pub mod account; 4 | pub mod api; 5 | pub mod bigint; 6 | pub mod browser; 7 | pub mod credential; 8 | pub(crate) mod error; 9 | pub mod graphql; 10 | pub mod preset; 11 | pub mod read; 12 | pub mod server; 13 | pub mod session; 14 | pub mod utils; 15 | pub mod vars; 16 | pub mod version; 17 | 18 | pub use account_sdk; 19 | pub use error::Error; 20 | use update_informer as _; 21 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::enum_variant_names)] 2 | 3 | pub type Long = u64; 4 | pub type Time = String; 5 | 6 | mod accounts; 7 | mod create; 8 | mod delete; 9 | mod describe; 10 | mod list; 11 | mod logs; 12 | mod transfer; 13 | mod update; 14 | 15 | pub use accounts::*; 16 | pub use create::*; 17 | pub use delete::*; 18 | pub use describe::*; 19 | pub use list::*; 20 | pub use logs::*; 21 | pub use transfer::*; 22 | pub use update::*; 23 | -------------------------------------------------------------------------------- /slot/src/graphql/merkle_drop/mod.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | use starknet::core::types::Felt; 3 | 4 | // Import Time type from another module (following pattern from other modules) 5 | pub use crate::graphql::paymaster::Time; 6 | 7 | #[derive(GraphQLQuery)] 8 | #[graphql( 9 | schema_path = "schema.json", 10 | query_path = "src/graphql/merkle_drop/create.graphql", 11 | response_derives = "Debug, Clone" 12 | )] 13 | pub struct CreateMerkleDrop; 14 | -------------------------------------------------------------------------------- /slot/src/vars.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | pub fn get_cartridge_keychain_url() -> String { 4 | get_env("CARTRIDGE_KEYCHAIN_URL", "https://x.cartridge.gg") 5 | } 6 | 7 | pub fn get_cartridge_api_url() -> String { 8 | get_env("CARTRIDGE_API_URL", "https://api.cartridge.gg") 9 | } 10 | 11 | pub fn get_env(key: &str, default: &str) -> String { 12 | match env::var(key) { 13 | Ok(val) => val, 14 | Err(_e) => default.to_string(), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /slot/src/graphql/rpc/list_whitelist_origins.graphql: -------------------------------------------------------------------------------- 1 | # Query to list RPC CORS domains 2 | # Operation name `ListRpcCorsDomains` must match the struct name in Rust 3 | query ListRpcCorsDomains($teamName: String!, $first: Int, $after: Cursor, $where: RPCCorsDomainWhereInput) { 4 | rpcCorsDomains(teamName: $teamName, first: $first, after: $after, where: $where) { 5 | edges { 6 | node { 7 | id 8 | domain 9 | createdAt 10 | } 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/info.graphql: -------------------------------------------------------------------------------- 1 | query PaymasterInfo($name: ID!) { 2 | paymaster(name: $name) { 3 | budget 4 | budgetFeeUnit 5 | strkFees 6 | creditFees 7 | active 8 | successfulTransactions 9 | revertedTransactions 10 | legacyStrkFees 11 | legacyEthFees 12 | legacyRevertedTransactions 13 | legacySuccessfulTransactions 14 | createdAt 15 | policies { 16 | totalCount 17 | } 18 | team { 19 | name 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /slot/src/graphql/team/members.graphql: -------------------------------------------------------------------------------- 1 | query TeamMembersList($team: String!) { 2 | team(name: $team) { 3 | deleted 4 | members { 5 | edges { 6 | node { 7 | id 8 | } 9 | } 10 | } 11 | } 12 | } 13 | 14 | mutation TeamMemberAdd($team: ID!, $accounts: [String!]!) { 15 | addToTeam(name: $team, usernames: $accounts) 16 | } 17 | 18 | mutation TeamMemberRemove($team: ID!, $accounts: [String!]!) { 19 | removeFromTeam(name: $team, usernames: $accounts) 20 | } 21 | -------------------------------------------------------------------------------- /slot/src/graphql/team/teams.graphql: -------------------------------------------------------------------------------- 1 | query TeamMembersList($team: String!) { 2 | team(name: $team) { 3 | deleted 4 | members { 5 | edges { 6 | node { 7 | id 8 | } 9 | } 10 | } 11 | } 12 | } 13 | 14 | mutation TeamMemberAdd($team: ID!, $accounts: [String!]!) { 15 | addToTeam(name: $team, usernames: $accounts) 16 | } 17 | 18 | mutation TeamMemberRemove($team: ID!, $accounts: [String!]!) { 19 | removeFromTeam(name: $team, usernames: $accounts) 20 | } 21 | -------------------------------------------------------------------------------- /slot/src/graphql/rpc/list_tokens.graphql: -------------------------------------------------------------------------------- 1 | # Query to list RPC API keys 2 | # Operation name `ListRpcApiKeys` must match the struct name in Rust 3 | query ListRpcApiKeys($teamName: String!, $first: Int, $after: Cursor, $where: RPCApiKeyWhereInput) { 4 | rpcApiKeys(teamName: $teamName, first: $first, after: $after, where: $where) { 5 | edges { 6 | node { 7 | id 8 | name 9 | keyPrefix 10 | active 11 | createdAt 12 | lastUsedAt 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /slot/src/graphql/deployments/list.graphql: -------------------------------------------------------------------------------- 1 | query ListDeployments { 2 | me { 3 | id 4 | name 5 | teams { 6 | edges { 7 | node { 8 | name 9 | deployments { 10 | edges { 11 | node { 12 | project 13 | branch 14 | status 15 | service { 16 | id 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/update.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateDeployment( 2 | $project: String! 3 | $service: UpdateServiceInput! 4 | $tier: DeploymentTier 5 | $wait: Boolean 6 | $observability: Boolean 7 | ) { 8 | updateDeployment( 9 | name: $project 10 | service: $service 11 | tier: $tier 12 | wait: $wait 13 | observability: $observability 14 | ) { 15 | __typename 16 | id 17 | version 18 | config { 19 | configFile 20 | } 21 | observabilitySecret 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/list_policies.graphql: -------------------------------------------------------------------------------- 1 | # Query to list policies from a paymaster 2 | # Operation name `ListPolicies` must match the struct name in Rust 3 | query ListPolicies($name: ID!) { 4 | paymaster(name: $name) { 5 | id 6 | name 7 | team { 8 | id 9 | name 10 | } 11 | policies { 12 | edges { 13 | node { 14 | id 15 | contractAddress 16 | entryPoint 17 | selector 18 | active 19 | } 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /scripts/pull_schema.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # https://github.com/graphql-rust/graphql-client/blob/main/graphql_client_cli/README.md 4 | # cargo install graphql_client_cli 5 | 6 | # check if param to pull schema is set to "local" 7 | if [ "$1" = "local" ]; then 8 | graphql-client introspect-schema --output slot/schema.json http://localhost:8000/query 9 | elif [ "$1" = "prod" ]; then 10 | graphql-client introspect-schema --output slot/schema.json https://api.cartridge.gg/query 11 | else 12 | echo "usage: pull_schema.sh [local|prod]" 13 | fi 14 | -------------------------------------------------------------------------------- /cli/src/command/paymasters/mod.rs: -------------------------------------------------------------------------------- 1 | use self::list::ListArgs; 2 | use anyhow::Result; 3 | use clap::Subcommand; 4 | mod list; 5 | 6 | #[derive(Subcommand, Debug)] 7 | #[command(next_help_heading = "Paymasters options")] 8 | pub enum PaymastersCmd { 9 | #[command(about = "List paymasters for the current user.", aliases = ["ls"])] 10 | List(ListArgs), 11 | } 12 | 13 | impl PaymastersCmd { 14 | pub async fn run(&self) -> Result<()> { 15 | match self { 16 | Self::List(args) => args.run().await, 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /slot/src/browser.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use tracing::trace; 3 | 4 | pub fn open(url: &str) -> Result<()> { 5 | trace!(%url, "Opening browser."); 6 | match webbrowser::open(url) { 7 | Ok(_) => { 8 | println!("Your browser has been opened to visit: \n\n {url}\n"); 9 | Ok(()) 10 | } 11 | Err(_) => { 12 | println!("Failed to open web browser automatically."); 13 | println!("Please open this URL in your browser:\n\n {url}\n"); 14 | Ok(()) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /slot/src/graphql/deployments/create.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateDeployment( 2 | $project: String! 3 | $service: CreateServiceInput! 4 | $tier: DeploymentTier! 5 | $wait: Boolean 6 | $regions: [String!] 7 | $team: String 8 | $observability: Boolean 9 | ) { 10 | createDeployment( 11 | name: $project 12 | service: $service 13 | tier: $tier 14 | wait: $wait 15 | regions: $regions 16 | team: $team 17 | observability: $observability 18 | ) { 19 | __typename 20 | id 21 | version 22 | config { 23 | configFile 24 | } 25 | observabilitySecret 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/transaction.graphql: -------------------------------------------------------------------------------- 1 | # Query to fetch paymaster transactions 2 | # Operation name `PaymasterTransactions` must match the struct name in Rust 3 | query PaymasterTransactions( 4 | $paymasterName: ID! 5 | $filter: PaymasterTransactionFilter 6 | $orderBy: PaymasterTransactionOrder 7 | $since: Time! 8 | $limit: Int 9 | ) { 10 | paymasterTransactions( 11 | paymasterName: $paymasterName 12 | filter: $filter 13 | orderBy: $orderBy 14 | since: $since 15 | limit: $limit 16 | ) { 17 | transactionHash 18 | status 19 | usdFee 20 | executedAt 21 | } 22 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["cli", "slot"] 4 | 5 | [workspace.package] 6 | version = "0.56.0" 7 | license-file = "LICENSE" 8 | repository = "https://github.com/cartridge-gg/slot/" 9 | edition = "2021" 10 | rust-version = "1.76.0" 11 | 12 | [workspace.dependencies] 13 | slot = { path = "slot" } 14 | 15 | anyhow = "1.0.100" 16 | axum = "0.8" 17 | graphql_client = "0.14.0" 18 | hyper = "1.4" 19 | tokio = { version = "1.46.1", features = ["full", "sync"] } 20 | serde = "1" 21 | serde_json = "1.0.145" 22 | thiserror = "2.0.12" 23 | url = "2.2.2" 24 | starknet = "0.14.0" 25 | num-bigint = "0.4.3" 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Cargo dependencies 4 | - package-ecosystem: "cargo" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | open-pull-requests-limit: 10 10 | 11 | # Rust toolchain 12 | - package-ecosystem: "rust-toolchain" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | day: "monday" 17 | open-pull-requests-limit: 5 18 | 19 | # GitHub Actions 20 | - package-ecosystem: "github-actions" 21 | directory: "/" 22 | schedule: 23 | interval: "weekly" 24 | day: "monday" 25 | open-pull-requests-limit: 5 26 | -------------------------------------------------------------------------------- /slot/src/graphql/merkle_drop/create.graphql: -------------------------------------------------------------------------------- 1 | # Mutation to create a new merkle drop 2 | mutation CreateMerkleDrop( 3 | $key: String!, 4 | $network: MerkleDropNetwork!, 5 | $description: String, 6 | $contract: Felt!, 7 | $entrypoint: String!, 8 | $salt: String!, 9 | $claims: [MerkleClaimInput!]! 10 | ) { 11 | createMerkleDrop( 12 | key: $key, 13 | network: $network, 14 | description: $description, 15 | contract: $contract, 16 | entrypoint: $entrypoint, 17 | salt: $salt, 18 | claims: $claims 19 | ) { 20 | id 21 | description 22 | network 23 | contract 24 | entrypoint 25 | merkleRoot 26 | createdAt 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /slot/src/account.rs: -------------------------------------------------------------------------------- 1 | pub use crate::graphql::auth::me::MeMeCredentialsWebauthn as WebAuthnCredential; 2 | use serde::{Deserialize, Serialize}; 3 | use starknet::core::types::Felt; 4 | 5 | /// Controller account information. 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | #[cfg_attr(test, derive(Default))] 8 | pub struct AccountInfo { 9 | pub id: String, 10 | pub username: String, 11 | pub controllers: Vec, 12 | pub credentials: Vec, 13 | } 14 | 15 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 16 | pub struct Controller { 17 | pub id: String, 18 | /// The address of the Controller contract. 19 | pub address: Felt, 20 | } 21 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(test), warn(unused_crate_dependencies))] 2 | use num_bigint as _; 3 | use update_informer as _; 4 | 5 | mod command; 6 | 7 | use crate::command::Command; 8 | use clap::Parser; 9 | 10 | /// Slot CLI for Cartridge 11 | #[derive(Parser, Debug)] 12 | #[command(author, version, about, long_about = None)] 13 | pub struct Cli { 14 | #[command(subcommand)] 15 | pub command: Command, 16 | } 17 | 18 | #[tokio::main] 19 | async fn main() { 20 | env_logger::init(); 21 | let cli = Cli::parse(); 22 | 23 | match &cli.command.run().await { 24 | Ok(_) => {} 25 | Err(e) => { 26 | eprintln!("{e}"); 27 | std::process::exit(1); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/list_paymasters.graphql: -------------------------------------------------------------------------------- 1 | # Query to list paymasters with pagination, filtering, and ordering 2 | # Operation name `ListPaymasters` must match the struct name in Rust 3 | query ListPaymasters { 4 | me { 5 | id 6 | teams { 7 | edges { 8 | node { 9 | name 10 | paymasters { 11 | edges { 12 | node { 13 | id 14 | name 15 | active 16 | budget 17 | budgetFeeUnit 18 | team { 19 | id 20 | name 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | pull_request: 5 | schedule: 6 | - cron: '0 8 * * *' 7 | - cron: '0 14 * * *' 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUST_VERSION: 1.85.0 12 | 13 | jobs: 14 | e2e: 15 | if: github.actor != 'dependabot[bot]' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | - uses: actions-rust-lang/setup-rust-toolchain@v1 20 | with: 21 | toolchain: ${{ env.RUST_VERSION }} 22 | components: clippy 23 | # Required to build Katana at the moment. 24 | - uses: oven-sh/setup-bun@v1 25 | with: 26 | bun-version: latest 27 | - run: sh ./scripts/e2e.sh 28 | env: 29 | SLOT_AUTH: ${{ secrets.SLOT_AUTH }} 30 | -------------------------------------------------------------------------------- /slot/src/read.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine, Engine}; 2 | 3 | pub fn read_and_encode_file_as_base64(file_path: Option) -> anyhow::Result> { 4 | if let Some(path) = file_path { 5 | let file_contents = std::fs::read(path)?; 6 | Ok(Some(base64_encode_bytes(&file_contents))) 7 | } else { 8 | Ok(None) 9 | } 10 | } 11 | 12 | pub fn base64_encode_bytes(data: &[u8]) -> String { 13 | engine::general_purpose::STANDARD.encode(data) 14 | } 15 | 16 | pub fn base64_encode_string(data: &str) -> String { 17 | engine::general_purpose::STANDARD.encode(data) 18 | } 19 | 20 | pub fn base64_decode_string(data: &str) -> Result { 21 | engine::general_purpose::STANDARD 22 | .decode(data) 23 | .map(|v| String::from_utf8(v).unwrap()) 24 | } 25 | -------------------------------------------------------------------------------- /slot/src/graphql/team/invoices.graphql: -------------------------------------------------------------------------------- 1 | query TeamInvoices($team: String!, $first: Int, $after: Cursor, $orderBy: InvoiceOrder) { 2 | team(name: $team) { 3 | id 4 | name 5 | invoices(first: $first, after: $after, orderBy: $orderBy) { 6 | edges { 7 | node { 8 | id 9 | teamID 10 | month 11 | totalCredits 12 | totalDebits 13 | slotDebits 14 | paymasterDebits 15 | incubatorCredits 16 | netAmount 17 | incubatorStage 18 | createdAt 19 | updatedAt 20 | finalized 21 | } 22 | cursor 23 | } 24 | pageInfo { 25 | hasNextPage 26 | hasPreviousPage 27 | startCursor 28 | endCursor 29 | } 30 | totalCount 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /.claude/commands/pr.md: -------------------------------------------------------------------------------- 1 | # PR workflow 2 | 3 | Make sure you are not on the main branch. If so, then start by creating a 4 | dedicated new one. Use the [commit](./commit.md) command if there are any 5 | pending changes. 6 | 7 | Use the git and gh cli tools to fetch the diff between origin/main and the 8 | current branch. Generate a concise summary of the content and purpose of these 9 | changes based on the observed diff. 10 | 11 | If some $ARGUMENTS are given, add to the summary "Close $ARGUMENTS". Use the 12 | Linear MCP to fetch the corresponding $ARGUMENTS issue and make sure that the PR 13 | content matches the issue description. 14 | 15 | Wait for few minutes after the PR has been created to find a comment from Claude 16 | making a detailed review of the PR. If the recommendation is not to approve, 17 | work on the suggested improvements. 18 | -------------------------------------------------------------------------------- /cli/src/command/merkle_drops/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Subcommand; 3 | 4 | // Import the structs defined in the subcommand files 5 | use self::create::CreateArgs; 6 | use self::snapshot::SnapshotArgs; 7 | mod create; 8 | mod snapshot; 9 | 10 | #[derive(Subcommand, Debug)] 11 | #[command(next_help_heading = "Merkle drops options")] 12 | pub enum MerkleDropsCmd { 13 | #[command(about = "Generate a snapshot of onchain token holders.", aliases = ["s"])] 14 | Snapshot(SnapshotArgs), 15 | #[command(about = "Create a new merkle drop.", aliases = ["c"])] 16 | Create(CreateArgs), 17 | } 18 | 19 | impl MerkleDropsCmd { 20 | pub async fn run(&self) -> Result<()> { 21 | match self { 22 | Self::Snapshot(args) => args.run().await, 23 | Self::Create(args) => args.run().await, 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /slot/src/graphql/rpc/logs.graphql: -------------------------------------------------------------------------------- 1 | # Query to list RPC logs 2 | # Operation name `ListRpcLogs` must match the struct name in Rust 3 | query ListRpcLogs($teamName: String!, $first: Int, $after: Cursor, $where: RPCLogWhereInput) { 4 | rpcLogs(teamName: $teamName, first: $first, after: $after, where: $where) { 5 | edges { 6 | node { 7 | id 8 | teamID 9 | apiKeyID 10 | corsDomainID 11 | clientIP 12 | userAgent 13 | referer 14 | network 15 | method 16 | responseStatus 17 | responseSizeBytes 18 | durationMs 19 | isInternal 20 | costCredits 21 | timestamp 22 | processedAt 23 | } 24 | cursor 25 | } 26 | pageInfo { 27 | hasNextPage 28 | hasPreviousPage 29 | startCursor 30 | endCursor 31 | } 32 | totalCount 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cli/src/command/auth/email.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::graphql::auth::{update_me::*, UpdateMe}; 4 | use slot::graphql::GraphQLQuery; 5 | use slot::{api::Client, credential::Credentials}; 6 | 7 | #[derive(Debug, Args)] 8 | #[command(next_help_heading = "Set email options")] 9 | pub struct EmailArgs { 10 | #[arg(help = "The email address of the user.")] 11 | pub email: String, 12 | } 13 | 14 | impl EmailArgs { 15 | pub async fn run(&self) -> Result<()> { 16 | let credentials = Credentials::load()?; 17 | let client = Client::new_with_token(credentials.access_token); 18 | 19 | let request_body = UpdateMe::build_query(Variables { 20 | email: Some(self.email.clone()), 21 | }); 22 | let res: ResponseData = client.query(&request_body).await?; 23 | print!("{:?}", res); 24 | 25 | Ok(()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cli/src/command/teams/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::team::delete_team; 6 | use slot::graphql::team::DeleteTeam; 7 | use slot::graphql::GraphQLQuery; 8 | 9 | #[derive(Debug, Args)] 10 | pub struct DeleteTeamArgs {} 11 | 12 | impl DeleteTeamArgs { 13 | pub async fn run(&self, name: String) -> Result<()> { 14 | let request_body = DeleteTeam::build_query(delete_team::Variables { name: name.clone() }); 15 | 16 | let credentials = Credentials::load()?; 17 | let client = Client::new_with_token(credentials.access_token); 18 | 19 | let data: delete_team::ResponseData = client.query(&request_body).await?; 20 | 21 | if data.delete_team { 22 | println!("Team '{}' deleted successfully", name); 23 | } else { 24 | println!("Failed to delete team '{}'", name); 25 | } 26 | 27 | Ok(()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /slot/src/bigint.rs: -------------------------------------------------------------------------------- 1 | use num_bigint::BigInt as NumBigInt; 2 | use serde::{Deserialize, Serialize}; 3 | use std::str::FromStr; 4 | 5 | #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] 6 | pub struct BigInt(NumBigInt); 7 | 8 | impl FromStr for BigInt { 9 | type Err = String; 10 | 11 | fn from_str(s: &str) -> Result { 12 | NumBigInt::from_str(s) 13 | .map(BigInt) 14 | .map_err(|_| format!("Failed to parse BigInt from string: {}", s)) 15 | } 16 | } 17 | 18 | impl std::fmt::Display for BigInt { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | write!(f, "{}", self.0) 21 | } 22 | } 23 | 24 | #[cfg(test)] 25 | mod tests { 26 | use super::*; 27 | 28 | #[test] 29 | fn test_bigint_from_str() { 30 | let input = "123"; 31 | let result = BigInt::from_str(input); 32 | assert!(result.is_ok(), "BigInt::from_str failed on '{}'", input); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cli/src/command/rpc/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Subcommand; 3 | 4 | use self::logs::LogsArgs; 5 | use self::tokens::TokensCmd; 6 | use self::whitelist::WhitelistCmd; 7 | 8 | mod logs; 9 | mod tokens; 10 | mod whitelist; 11 | 12 | /// Command group for managing RPC tokens and configurations 13 | #[derive(Subcommand, Debug)] 14 | pub enum RpcCmd { 15 | #[command(about = "Manage RPC tokens.", alias = "t")] 16 | Tokens(TokensCmd), 17 | 18 | #[command(about = "Manage origin whitelist.", alias = "w")] 19 | Whitelist(WhitelistCmd), 20 | 21 | #[command(about = "View RPC logs.", alias = "l")] 22 | Logs(LogsArgs), 23 | } 24 | 25 | impl RpcCmd { 26 | // Main entry point for the RPC command group 27 | pub async fn run(&self) -> Result<()> { 28 | match &self { 29 | RpcCmd::Tokens(cmd) => cmd.run().await, 30 | RpcCmd::Whitelist(cmd) => cmd.run().await, 31 | RpcCmd::Logs(args) => args.run().await, 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:slim-buster as builder 2 | RUN apt-get -y update; \ 3 | apt-get install -y --no-install-recommends \ 4 | curl libssl-dev make clang-11 g++ llvm \ 5 | pkg-config libz-dev zstd git; \ 6 | apt-get autoremove -y; \ 7 | apt-get clean; \ 8 | rm -rf /var/lib/apt/lists/* 9 | 10 | WORKDIR /slot 11 | COPY . . 12 | RUN cargo build --release --config net.git-fetch-with-cli=true 13 | 14 | FROM debian:buster-slim 15 | LABEL description="Slot is a toolchain for rapidly spinning up managed Katana and Torii instances. Play test your game in seconds." \ 16 | authors="tarrence " \ 17 | source="https://github.com/cartridge-gg/slot" \ 18 | documentation="https://github.com/cartridge-gg/slot" 19 | 20 | RUN apt-get -y update; \ 21 | apt-get install -y --no-install-recommends \ 22 | curl; \ 23 | apt-get autoremove -y; \ 24 | apt-get clean; \ 25 | rm -rf /var/lib/apt/lists/* 26 | 27 | COPY --from=builder /slot/target/release/slot /usr/local/bin/slot 28 | 29 | -------------------------------------------------------------------------------- /slot/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::api::{self}; 2 | use account_sdk::signers::SignError; 3 | use starknet::core::utils::NonAsciiNameError; 4 | 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum Error { 7 | #[error(transparent)] 8 | Io(#[from] std::io::Error), 9 | 10 | #[error("No credentials found, please authenticate with `slot auth login`")] 11 | Unauthorized, 12 | 13 | #[error("Malformed credentials, please reauthenticate with `slot auth login`")] 14 | MalformedCredentials, 15 | 16 | #[error(transparent)] 17 | ReqwestError(#[from] reqwest::Error), 18 | 19 | #[error("Invalid OAuth token, please authenticate with `slot auth login`")] 20 | InvalidOAuth, 21 | 22 | #[error(transparent)] 23 | Serde(#[from] serde_json::Error), 24 | 25 | #[error(transparent)] 26 | Anyhow(#[from] anyhow::Error), 27 | 28 | #[error("Invalid method name: {0}")] 29 | InvalidMethodName(NonAsciiNameError), 30 | 31 | #[error(transparent)] 32 | Signing(#[from] SignError), 33 | 34 | #[error(transparent)] 35 | Api(#[from] api::GraphQLErrors), 36 | } 37 | -------------------------------------------------------------------------------- /cli/src/command/auth/token.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::credential::Credentials; 4 | 5 | #[derive(Debug, Args)] 6 | pub struct TokenArgs; 7 | 8 | impl TokenArgs { 9 | pub async fn run(&self) -> Result<()> { 10 | let credentials = Credentials::load()?; 11 | 12 | eprintln!(); 13 | eprintln!( 14 | "WARNING: This token provides access to your account for slot actions. Keep it safe." 15 | ); 16 | eprintln!(); 17 | 18 | // Print the raw token 19 | println!("{}", credentials.access_token.token); 20 | 21 | // Print usage instructions to stderr so they don't interfere with token parsing 22 | eprintln!(); 23 | eprintln!("You can use this token for programmatic authentication by setting:"); 24 | eprintln!( 25 | "export SLOT_AUTH='{}'", 26 | serde_json::to_string(&credentials)? 27 | ); 28 | eprintln!(); 29 | eprintln!("This is useful for CI/CD pipelines and scripts where interactive authentication is not possible."); 30 | 31 | Ok(()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cli/src/command/rpc/tokens.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Args, Subcommand}; 3 | 4 | use self::create::CreateArgs; 5 | use self::delete::DeleteArgs; 6 | use self::list::ListArgs; 7 | 8 | mod create; 9 | mod delete; 10 | mod list; 11 | 12 | /// Command group for managing RPC tokens 13 | #[derive(Debug, Args)] 14 | pub struct TokensCmd { 15 | #[command(subcommand)] 16 | command: TokensSubcommand, 17 | } 18 | 19 | // Enum defining the specific token actions 20 | #[derive(Subcommand, Debug)] 21 | enum TokensSubcommand { 22 | #[command(about = "Create a new RPC token.", alias = "c")] 23 | Create(CreateArgs), 24 | 25 | #[command(about = "Delete an RPC token.", alias = "d")] 26 | Delete(DeleteArgs), 27 | 28 | #[command(about = "List RPC tokens.", alias = "l")] 29 | List(ListArgs), 30 | } 31 | 32 | impl TokensCmd { 33 | pub async fn run(&self) -> Result<()> { 34 | match &self.command { 35 | TokensSubcommand::Create(args) => args.run().await, 36 | TokensSubcommand::Delete(args) => args.run().await, 37 | TokensSubcommand::List(args) => args.run().await, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cli/src/command/rpc/whitelist.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Args, Subcommand}; 3 | 4 | use self::add::AddArgs; 5 | use self::list::ListArgs; 6 | use self::remove::RemoveArgs; 7 | 8 | mod add; 9 | mod list; 10 | mod remove; 11 | 12 | /// Command group for managing origin whitelist 13 | #[derive(Debug, Args)] 14 | pub struct WhitelistCmd { 15 | #[command(subcommand)] 16 | command: WhitelistSubcommand, 17 | } 18 | 19 | // Enum defining the specific whitelist actions 20 | #[derive(Subcommand, Debug)] 21 | enum WhitelistSubcommand { 22 | #[command(about = "Add origin to whitelist.", alias = "a")] 23 | Add(AddArgs), 24 | 25 | #[command(about = "Remove origin from whitelist.", alias = "r")] 26 | Remove(RemoveArgs), 27 | 28 | #[command(about = "List whitelisted origins.", alias = "l")] 29 | List(ListArgs), 30 | } 31 | 32 | impl WhitelistCmd { 33 | pub async fn run(&self) -> Result<()> { 34 | match &self.command { 35 | WhitelistSubcommand::Add(args) => args.run().await, 36 | WhitelistSubcommand::Remove(args) => args.run().await, 37 | WhitelistSubcommand::List(args) => args.run().await, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /slot/src/graphql/auth/info.graphql: -------------------------------------------------------------------------------- 1 | query Me { 2 | me { 3 | id 4 | username 5 | creditsPlain 6 | 7 | teams { 8 | edges { 9 | node { 10 | id 11 | name 12 | credits 13 | deleted 14 | incubatorStage 15 | totalDebits 16 | 17 | membership { 18 | edges { 19 | node { 20 | account { 21 | id 22 | username 23 | } 24 | role 25 | } 26 | } 27 | } 28 | 29 | deployments { 30 | edges { 31 | node { 32 | id 33 | project 34 | branch 35 | serviceID 36 | status 37 | deprecated 38 | } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | controllers { 46 | edges { 47 | node { 48 | id 49 | address 50 | 51 | signers { 52 | id 53 | type 54 | } 55 | } 56 | } 57 | } 58 | 59 | credentials { 60 | webauthn { 61 | id 62 | publicKey 63 | } 64 | } 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /slot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "slot" 3 | version.workspace = true 4 | edition.workspace = true 5 | license-file.workspace = true 6 | rust-version.workspace = true 7 | 8 | [dependencies] 9 | anyhow.workspace = true 10 | axum.workspace = true 11 | cainome-cairo-serde = "0.2.0" 12 | dirs = "6" 13 | graphql_client.workspace = true 14 | reqwest = { version = "0.12", default-features = false, features = [ 15 | "rustls-tls", 16 | "json", 17 | ] } 18 | serde.workspace = true 19 | serde_json.workspace = true 20 | thiserror.workspace = true 21 | tokio.workspace = true 22 | tower-http = { version = "0.6", features = ["cors", "trace"] } 23 | tracing = "0.1.43" 24 | urlencoding = "2" 25 | webbrowser = "1.0" 26 | starknet.workspace = true 27 | url.workspace = true 28 | tempfile = "3.23.0" 29 | hyper.workspace = true 30 | # https://github.com/cartridge-gg/controller/pull/1549 31 | account_sdk = { git = "https://github.com/cartridge-gg/controller", rev = "05fe96f4" } 32 | base64 = "0.22.1" 33 | colored = "3.0.0" 34 | num-bigint = { version = "0.4.6", features = ["serde"] } 35 | update-informer = { version = "1.3", default-features = false, features = [ 36 | "ureq", 37 | "github", 38 | "rustls-tls", 39 | ] } 40 | which = "7.0.2" 41 | dialoguer = "0.12.0" 42 | regex = "1.12" 43 | 44 | [dev-dependencies] 45 | assert_matches = "1.5.0" 46 | -------------------------------------------------------------------------------- /cli/src/command/rpc/tokens/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::rpc::delete_rpc_api_key; 6 | use slot::graphql::rpc::DeleteRpcApiKey; 7 | use slot::graphql::GraphQLQuery; 8 | 9 | #[derive(Debug, Args)] 10 | #[command(next_help_heading = "Delete RPC token options")] 11 | pub struct DeleteArgs { 12 | #[arg(help = "ID of the RPC token to delete.")] 13 | id: String, 14 | } 15 | 16 | impl DeleteArgs { 17 | pub async fn run(&self) -> Result<()> { 18 | let credentials = Credentials::load()?; 19 | 20 | let variables = delete_rpc_api_key::Variables { 21 | id: self.id.clone(), 22 | }; 23 | let request_body = DeleteRpcApiKey::build_query(variables); 24 | 25 | let client = Client::new_with_token(credentials.access_token); 26 | 27 | let data: delete_rpc_api_key::ResponseData = client.query(&request_body).await?; 28 | 29 | if data.delete_rpc_api_key { 30 | println!("\n✅ RPC API Key Deleted Successfully"); 31 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 32 | println!("🗑️ API Key ID {} has been removed", self.id); 33 | } else { 34 | println!("❌ Failed to delete RPC API key"); 35 | } 36 | 37 | Ok(()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cli/src/command/auth/session.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use anyhow::{anyhow, ensure, Result}; 4 | use clap::Parser; 5 | use slot::session::{self, PolicyMethod}; 6 | use starknet::core::types::Felt; 7 | use url::Url; 8 | 9 | #[derive(Debug, Parser)] 10 | pub struct CreateSession { 11 | #[arg(long)] 12 | #[arg(value_name = "URL")] 13 | // #[arg(default_value = "http://localhost:5050")] 14 | #[arg(help = "The RPC URL of the network you want to create a session for.")] 15 | rpc_url: String, 16 | 17 | #[arg(help = "The session's policies.")] 18 | #[arg(value_parser = parse_policy_method)] 19 | #[arg(required = true)] 20 | policies: Vec, 21 | } 22 | 23 | impl CreateSession { 24 | pub async fn run(&self) -> Result<()> { 25 | let url = Url::parse(&self.rpc_url)?; 26 | let session = session::create(url, &self.policies).await?; 27 | session::store(session.chain_id, &session)?; 28 | Ok(()) 29 | } 30 | } 31 | 32 | fn parse_policy_method(value: &str) -> Result { 33 | let mut parts = value.split(','); 34 | 35 | let target = parts.next().ok_or(anyhow!("missing target"))?.to_owned(); 36 | let target = Felt::from_str(&target)?; 37 | let method = parts.next().ok_or(anyhow!("missing method"))?.to_owned(); 38 | 39 | ensure!(parts.next().is_none()); 40 | 41 | Ok(PolicyMethod { target, method }) 42 | } 43 | -------------------------------------------------------------------------------- /cli/src/command/rpc/whitelist/remove.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::rpc::delete_rpc_cors_domain; 6 | use slot::graphql::rpc::DeleteRpcCorsDomain; 7 | use slot::graphql::GraphQLQuery; 8 | 9 | #[derive(Debug, Args)] 10 | #[command(next_help_heading = "Remove whitelist origin options")] 11 | pub struct RemoveArgs { 12 | #[arg(help = "ID of the whitelist origin to remove.")] 13 | id: String, 14 | } 15 | 16 | impl RemoveArgs { 17 | pub async fn run(&self) -> Result<()> { 18 | let credentials = Credentials::load()?; 19 | 20 | let variables = delete_rpc_cors_domain::Variables { 21 | id: self.id.clone(), 22 | }; 23 | let request_body = DeleteRpcCorsDomain::build_query(variables); 24 | 25 | let client = Client::new_with_token(credentials.access_token); 26 | 27 | let data: delete_rpc_cors_domain::ResponseData = client.query(&request_body).await?; 28 | 29 | if data.delete_rpc_cors_domain { 30 | println!("\n✅ Origin Removed from CORS Whitelist Successfully"); 31 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 32 | println!("🗑️ CORS domain ID {} has been removed", self.id); 33 | } else { 34 | println!("❌ Failed to remove origin from CORS whitelist"); 35 | } 36 | 37 | Ok(()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cli/src/command/deployments/services/mod.rs: -------------------------------------------------------------------------------- 1 | use clap::{Subcommand, ValueEnum}; 2 | 3 | use self::{ 4 | katana::{KatanaAccountArgs, KatanaCreateArgs, KatanaUpdateArgs}, 5 | torii::{ToriiCreateArgs, ToriiUpdateArgs}, 6 | }; 7 | 8 | mod katana; 9 | mod torii; 10 | 11 | #[derive(Debug, Subcommand, serde::Serialize)] 12 | #[serde(untagged)] 13 | pub enum CreateServiceCommands { 14 | #[command(about = "Katana deployment.")] 15 | Katana(KatanaCreateArgs), 16 | #[command(about = "Torii deployment.")] 17 | Torii(Box), 18 | } 19 | 20 | #[derive(Debug, Subcommand, serde::Serialize)] 21 | #[serde(untagged)] 22 | pub enum UpdateServiceCommands { 23 | #[command(about = "Katana deployment.")] 24 | Katana(KatanaUpdateArgs), 25 | #[command(about = "Torii deployment.")] 26 | Torii(Box), 27 | } 28 | 29 | #[derive(Debug, Subcommand, serde::Serialize)] 30 | #[serde(untagged)] 31 | pub enum KatanaAccountCommands { 32 | #[command(about = "Katana deployment.")] 33 | Katana(KatanaAccountArgs), 34 | } 35 | 36 | #[derive(Clone, Debug, ValueEnum, serde::Serialize)] 37 | pub enum Service { 38 | Katana, 39 | Torii, 40 | } 41 | 42 | impl std::fmt::Display for Service { 43 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 | match self { 45 | Service::Katana => write!(f, "katana"), 46 | Service::Torii => write!(f, "torii"), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "slot-cli" 3 | version = "0.56.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow.workspace = true 10 | axum.workspace = true 11 | chrono = { version = "0.4", features = ["serde"] } 12 | clap = { version = "4.5", features = ["derive"] } 13 | colored = "3.0.0" 14 | ctrlc = "3.5.1" 15 | dialoguer = "0.12.0" 16 | ethers = { version = "2.0", default-features = false, features = ["abigen", "rustls"] } 17 | futures = "0.3" 18 | env_logger = "0.11.3" 19 | log = "0.4" 20 | graphql_client.workspace = true 21 | 22 | # Revision that includes the `config` field being public. 23 | # Should be bumped once a new version is released with this commit: 24 | # 25 | katana-primitives = { git = "https://github.com/dojoengine/katana", rev = "d615ff05" } 26 | 27 | comfy-table = "7.2" 28 | hyper.workspace = true 29 | num-bigint = { version = "0.4", features = ["serde"] } 30 | tokio.workspace = true 31 | thiserror.workspace = true 32 | serde.workspace = true 33 | serde_json = "1.0" 34 | slot.workspace = true 35 | starknet.workspace = true 36 | url.workspace = true 37 | strum_macros = "0.27.2" 38 | update-informer = { version = "1.3", default-features = false, features = [ 39 | "ureq", 40 | "github", 41 | ] } 42 | 43 | [[bin]] 44 | name = "slot" 45 | path = "src/main.rs" 46 | 47 | [features] 48 | default = [] 49 | -------------------------------------------------------------------------------- /.github/workflows/schema-sync.yml: -------------------------------------------------------------------------------- 1 | name: Daily Schema Sync 2 | 3 | on: 4 | schedule: 5 | - cron: '0 2 * * *' # Run daily at 2 AM UTC 6 | workflow_dispatch: # Allow manual triggering 7 | 8 | jobs: 9 | sync-schema: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v6 15 | 16 | - name: Set up Rust 17 | uses: dtolnay/rust-toolchain@stable 18 | 19 | - name: Install GraphQL CLI 20 | run: cargo install graphql_client_cli 21 | 22 | - name: Run schema pull 23 | run: sh scripts/pull_schema.sh prod 24 | 25 | - name: Create Pull Request 26 | uses: peter-evans/create-pull-request@v7 27 | with: 28 | token: ${{ secrets.GH_TOKEN }} 29 | commit-message: "chore: update GraphQL schema from production" 30 | title: "chore(graphql): sync schema" 31 | body: | 32 | ## Summary 33 | 34 | Automated daily sync of GraphQL schema from production environment. 35 | 36 | This PR was automatically created by the daily schema sync workflow. 37 | 38 | ## Changes 39 | 40 | - Updated `slot/src/graphql/schema.graphql` from production API 41 | - Generated Rust code will be updated on merge if schema changed 42 | 43 | ## Next Steps 44 | 45 | - Review schema changes for breaking changes 46 | - Ensure generated code still compiles 47 | - Merge if changes look good 48 | branch: chore/daily-schema-sync 49 | delete-branch: true 50 | -------------------------------------------------------------------------------- /slot/src/graphql/team/mod.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | pub type Cursor = String; 4 | pub type Time = String; 5 | 6 | #[derive(GraphQLQuery)] 7 | #[graphql( 8 | response_derives = "Debug", 9 | schema_path = "schema.json", 10 | query_path = "src/graphql/team/create.graphql" 11 | )] 12 | pub struct CreateTeam; 13 | #[derive(GraphQLQuery)] 14 | #[graphql( 15 | response_derives = "Debug", 16 | schema_path = "schema.json", 17 | query_path = "src/graphql/team/update.graphql" 18 | )] 19 | pub struct UpdateTeam; 20 | 21 | #[derive(GraphQLQuery)] 22 | #[graphql( 23 | response_derives = "Debug", 24 | schema_path = "schema.json", 25 | query_path = "src/graphql/team/members.graphql" 26 | )] 27 | pub struct TeamMembersList; 28 | 29 | #[derive(GraphQLQuery)] 30 | #[graphql( 31 | response_derives = "Debug", 32 | schema_path = "schema.json", 33 | query_path = "src/graphql/team/members.graphql" 34 | )] 35 | pub struct TeamMemberAdd; 36 | 37 | #[derive(GraphQLQuery)] 38 | #[graphql( 39 | response_derives = "Debug", 40 | schema_path = "schema.json", 41 | query_path = "src/graphql/team/members.graphql" 42 | )] 43 | pub struct TeamMemberRemove; 44 | 45 | #[derive(GraphQLQuery)] 46 | #[graphql( 47 | response_derives = "Debug", 48 | schema_path = "schema.json", 49 | query_path = "src/graphql/team/delete.graphql" 50 | )] 51 | pub struct DeleteTeam; 52 | 53 | #[derive(GraphQLQuery)] 54 | #[graphql( 55 | response_derives = "Debug", 56 | schema_path = "schema.json", 57 | query_path = "src/graphql/team/invoices.graphql" 58 | )] 59 | pub struct TeamInvoices; 60 | -------------------------------------------------------------------------------- /cli/src/command/rpc/whitelist/add.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::rpc::create_rpc_cors_domain; 6 | use slot::graphql::rpc::CreateRpcCorsDomain; 7 | use slot::graphql::GraphQLQuery; 8 | 9 | #[derive(Debug, Args)] 10 | #[command(next_help_heading = "Add whitelist origin options")] 11 | pub struct AddArgs { 12 | #[arg(help = "Origin URL to add to whitelist.")] 13 | origin: String, 14 | 15 | #[arg(long, help = "Team name to add the origin for.")] 16 | team: String, 17 | } 18 | 19 | impl AddArgs { 20 | pub async fn run(&self) -> Result<()> { 21 | let credentials = Credentials::load()?; 22 | 23 | let variables = create_rpc_cors_domain::Variables { 24 | team_name: self.team.clone(), 25 | domain: self.origin.clone(), 26 | rate_limit_per_minute: Some(60), // Default rate limit 27 | }; 28 | let request_body = CreateRpcCorsDomain::build_query(variables); 29 | 30 | let client = Client::new_with_token(credentials.access_token); 31 | 32 | let data: create_rpc_cors_domain::ResponseData = client.query(&request_body).await?; 33 | 34 | println!("\n✅ Origin Added to CORS Whitelist Successfully"); 35 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 36 | 37 | println!("🌐 Details:"); 38 | println!(" • ID: {}", data.create_rpc_cors_domain.id); 39 | println!(" • Domain: {}", data.create_rpc_cors_domain.domain); 40 | println!(" • Team: {}", self.team); 41 | println!(" • Created: {}", data.create_rpc_cors_domain.created_at); 42 | 43 | Ok(()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /slotup/install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | echo Installing slotup... 5 | 6 | BASE_DIR=${XDG_CONFIG_HOME:-$HOME} 7 | SLOT_DIR=${SLOT_DIR-"$BASE_DIR/.slot"} 8 | SLOT_BIN_DIR="$SLOT_DIR/bin" 9 | SLOT_MAN_DIR="$SLOT_DIR/share/man/man1" 10 | 11 | BIN_URL="https://raw.githubusercontent.com/cartridge-gg/slot/main/slotup/slotup" 12 | BIN_PATH="$SLOT_BIN_DIR/slotup" 13 | 14 | 15 | # Create the .slot bin directory and slotup binary if it doesn't exist. 16 | mkdir -p $SLOT_BIN_DIR 17 | curl -# -L $BIN_URL -o $BIN_PATH 18 | chmod +x $BIN_PATH 19 | 20 | # Create the man directory for future man files if it doesn't exist. 21 | mkdir -p $SLOT_MAN_DIR 22 | 23 | # Store the correct profile file (i.e. .profile for bash or .zshenv for ZSH). 24 | case $SHELL in 25 | */zsh) 26 | PROFILE=${ZDOTDIR-"$HOME"}/.zshenv 27 | PREF_SHELL=zsh 28 | ;; 29 | */bash) 30 | PROFILE=$HOME/.bashrc 31 | PREF_SHELL=bash 32 | ;; 33 | */fish) 34 | PROFILE=$HOME/.config/fish/config.fish 35 | PREF_SHELL=fish 36 | ;; 37 | */ash) 38 | PROFILE=$HOME/.profile 39 | PREF_SHELL=ash 40 | ;; 41 | *) 42 | echo "slotup: could not detect shell, manually add ${SLOT_BIN_DIR} to your PATH." 43 | exit 1 44 | esac 45 | 46 | # Only add slotup if it isn't already in PATH. 47 | if [[ ":$PATH:" != *":${SLOT_BIN_DIR}:"* ]]; then 48 | # Add the slotup directory to the path and ensure the old PATH variables remain. 49 | echo >> $PROFILE && echo "export PATH=\"\$PATH:$SLOT_BIN_DIR\"" >> $PROFILE 50 | fi 51 | 52 | echo && echo "Detected your preferred shell is ${PREF_SHELL} and added slotup to PATH. Run 'source ${PROFILE}' or start a new terminal session to use slotup." 53 | echo "Then, simply run 'slotup' to install Slot." -------------------------------------------------------------------------------- /cli/src/command/auth/mod.rs: -------------------------------------------------------------------------------- 1 | use self::{email::EmailArgs, info::InfoArgs, login::LoginArgs, token::TokenArgs}; 2 | use crate::command::auth::fund::FundArgs; 3 | use crate::command::auth::transfer::TransferArgs; 4 | use anyhow::Result; 5 | use clap::Subcommand; 6 | 7 | mod email; 8 | mod fund; 9 | mod info; 10 | mod login; 11 | mod session; 12 | mod token; 13 | mod transfer; 14 | 15 | #[derive(Subcommand, Debug)] 16 | pub enum Auth { 17 | #[command(about = "Login to your Cartridge account.")] 18 | Login(LoginArgs), 19 | 20 | #[command(about = "Display info about the authenticated user.")] 21 | Info(InfoArgs), 22 | 23 | #[command(about = "Set the email address for the authenticated user.")] 24 | SetEmail(EmailArgs), 25 | 26 | #[command(about = "Fund the authenticated user's account.")] 27 | Fund(FundArgs), 28 | 29 | #[command(about = "Transfer funds to a slot team.")] 30 | Transfer(TransferArgs), 31 | 32 | #[command(about = "Display the current auth token.")] 33 | Token(TokenArgs), 34 | 35 | // Mostly for testing purposes, will eventually turn it into a library call from `sozo`. 36 | #[command(hide = true)] 37 | CreateSession(session::CreateSession), 38 | } 39 | 40 | impl Auth { 41 | pub async fn run(&self) -> Result<()> { 42 | match &self { 43 | Auth::Login(args) => args.run().await, 44 | Auth::Info(args) => args.run().await, 45 | Auth::CreateSession(args) => args.run().await, 46 | Auth::SetEmail(args) => args.run().await, 47 | Auth::Fund(args) => args.run().await, 48 | Auth::Transfer(args) => args.run().await, 49 | Auth::Token(args) => args.run().await, 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cli/src/command/teams/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::team::create_team; 6 | use slot::graphql::team::CreateTeam; 7 | use slot::graphql::GraphQLQuery; 8 | use slot::utils::is_valid_email; 9 | 10 | #[derive(Debug, Args, serde::Serialize)] 11 | #[command(next_help_heading = "Team create options")] 12 | pub struct CreateTeamArgs { 13 | #[arg(long)] 14 | #[arg(help = "The email address for team notifications.")] 15 | pub email: String, 16 | 17 | #[arg(long)] 18 | #[arg(help = "The physical address for the team.")] 19 | pub address: Option, 20 | 21 | #[arg(long)] 22 | #[arg(help = "The tax ID for the team.")] 23 | pub tax_id: Option, 24 | } 25 | 26 | impl CreateTeamArgs { 27 | pub async fn run(&self, team: String) -> Result<()> { 28 | // Validate email format 29 | if !is_valid_email(&self.email) { 30 | bail!("Invalid email format: {}", self.email); 31 | } 32 | 33 | let request_body = CreateTeam::build_query(create_team::Variables { 34 | name: team.clone(), 35 | input: Some(create_team::TeamInput { 36 | email: Some(self.email.clone()), 37 | address: self.address.clone(), 38 | tax_id: self.tax_id.clone(), 39 | }), 40 | }); 41 | 42 | let credentials = Credentials::load()?; 43 | let client = Client::new_with_token(credentials.access_token); 44 | 45 | let data: create_team::ResponseData = client.query(&request_body).await?; 46 | 47 | println!("Team {} created successfully 🚀", data.create_team.name); 48 | 49 | Ok(()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/release-dispatch.yml: -------------------------------------------------------------------------------- 1 | name: release-dispatch 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: Version to release 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | propose-release: 12 | permissions: 13 | pull-requests: write 14 | contents: write 15 | runs-on: ubuntu-latest 16 | container: 17 | image: ghcr.io/dojoengine/dojo-dev:5d61184 18 | steps: 19 | # Workaround described here: https://github.com/actions/checkout/issues/760 20 | - uses: actions/checkout@v6 21 | - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" 22 | - run: cargo release version ${{ inputs.version }} --execute --no-confirm && cargo release replace --execute --no-confirm 23 | - id: version_info 24 | run: | 25 | cargo install cargo-get 26 | echo "version=$(cargo get workspace.package.version)" >> $GITHUB_OUTPUT 27 | - uses: peter-evans/create-pull-request@v7 28 | id: pr 29 | with: 30 | # We have to use a PAT in order to trigger ci 31 | token: ${{ secrets.CREATE_PR_TOKEN }} 32 | title: "Prepare release: v${{ steps.version_info.outputs.version }}" 33 | commit-message: "Prepare release: v${{ steps.version_info.outputs.version }}" 34 | branch: prepare-release 35 | base: main 36 | delete-branch: true 37 | - name: Enable auto-squash 38 | if: ${{ steps.pr.outputs.pull-request-number }} 39 | run: gh pr merge --auto --squash "${{ steps.pr.outputs.pull-request-number }}" 40 | env: 41 | PR_URL: ${{ github.event.pull_request.html_url }} 42 | GITHUB_TOKEN: ${{ secrets.CREATE_PR_TOKEN }} 43 | 44 | -------------------------------------------------------------------------------- /cli/src/command/deployments/services/torii.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Args; 4 | 5 | #[derive(Clone, Debug, Args, serde::Serialize)] 6 | #[command(next_help_heading = "Torii create options")] 7 | pub struct ToriiCreateArgs { 8 | #[arg(long, short = 'c')] 9 | #[arg(help = "Path to the Torii configuration file (TOML format). This is required.")] 10 | pub config: PathBuf, 11 | 12 | #[arg(long, default_value = "1")] 13 | #[arg(help = "The number of replicas to deploy.")] 14 | pub replicas: Option, 15 | 16 | #[arg(long)] 17 | #[arg(help = "The version of Torii to deploy.")] 18 | pub version: Option, 19 | 20 | #[arg(long)] 21 | #[arg(help = "Enable database replication using litestream.")] 22 | pub replication: bool, 23 | } 24 | 25 | /// Update a Torii deployment. 26 | /// 27 | /// The main purpose of update is usually to change slot configuration (replicate, regions, tier, etc...), 28 | /// but it can also be used to change Torii parameters. 29 | /// For the latter, it is only possible using the configuration file (and not each individual parameter in the CLI), 30 | /// since the deployment has already been created with a configuration. 31 | #[derive(Clone, Debug, Args, serde::Serialize)] 32 | #[command(next_help_heading = "Torii update options")] 33 | pub struct ToriiUpdateArgs { 34 | #[arg(long)] 35 | #[arg(help = "The number of replicas to deploy.")] 36 | pub replicas: Option, 37 | 38 | #[arg(long)] 39 | #[arg(help = "The version of Torii to deploy.")] 40 | pub version: Option, 41 | 42 | #[arg(long)] 43 | #[arg( 44 | help = "The path to the configuration file to use for the update. This will replace the existing configuration." 45 | )] 46 | pub config: Option, 47 | } 48 | -------------------------------------------------------------------------------- /cli/src/command/teams/update.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::team::update_team; 6 | use slot::graphql::team::UpdateTeam; 7 | use slot::graphql::GraphQLQuery; 8 | use slot::utils::is_valid_email; 9 | 10 | #[derive(Debug, Args, serde::Serialize)] 11 | #[command(next_help_heading = "Team update options")] 12 | pub struct UpdateTeamArgs { 13 | #[arg(long)] 14 | #[arg(help = "The email address for team notifications.")] 15 | pub email: Option, 16 | 17 | #[arg(long)] 18 | #[arg(help = "The physical address for the team.")] 19 | pub address: Option, 20 | 21 | #[arg(long)] 22 | #[arg(help = "The tax ID for the team.")] 23 | pub tax_id: Option, 24 | } 25 | 26 | impl UpdateTeamArgs { 27 | pub async fn run(&self, team: String) -> Result<()> { 28 | // Validate email format if provided 29 | if let Some(email) = &self.email { 30 | if !is_valid_email(email) { 31 | bail!("Invalid email format: {}", email); 32 | } 33 | } 34 | 35 | let request_body = UpdateTeam::build_query(update_team::Variables { 36 | name: team.clone(), 37 | input: update_team::TeamInput { 38 | email: self.email.clone(), 39 | address: self.address.clone(), 40 | tax_id: self.tax_id.clone(), 41 | }, 42 | }); 43 | 44 | let credentials = Credentials::load()?; 45 | let client = Client::new_with_token(credentials.access_token); 46 | 47 | let _data: update_team::ResponseData = client.query(&request_body).await?; 48 | 49 | println!("Team {} updated successfully 🚀", team); 50 | 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use std::time::Duration; 3 | 4 | pub fn parse_duration(duration_str: &str) -> Result { 5 | // Parse duration strings like "1hr", "2min", "24hr", "1day", "1week" 6 | let duration_str = duration_str.to_lowercase(); 7 | 8 | // Extract number and unit 9 | let (number_str, unit) = if let Some(pos) = duration_str.find(char::is_alphabetic) { 10 | duration_str.split_at(pos) 11 | } else { 12 | return Err(anyhow!("Invalid duration format: {}", duration_str)); 13 | }; 14 | 15 | let number: u64 = number_str 16 | .parse() 17 | .map_err(|_| anyhow!("Invalid number in duration: {}", number_str))?; 18 | 19 | let duration = match unit { 20 | "s" | "sec" | "secs" | "second" | "seconds" => Duration::from_secs(number), 21 | "m" | "min" | "mins" | "minute" | "minutes" => Duration::from_secs(number * 60), 22 | "h" | "hr" | "hrs" | "hour" | "hours" => Duration::from_secs(number * 3600), 23 | "d" | "day" | "days" => Duration::from_secs(number * 86400), 24 | "w" | "week" | "weeks" => { 25 | if number > 1 { 26 | return Err(anyhow!("Maximum duration is 1 week")); 27 | } 28 | Duration::from_secs(number * 604800) 29 | } 30 | _ => { 31 | return Err(anyhow!( 32 | "Invalid time unit: {}. Supported units: s, m, h, d, w", 33 | unit 34 | )) 35 | } 36 | }; 37 | 38 | // Check maximum duration (1 week) 39 | let max_duration = Duration::from_secs(604800); // 1 week in seconds 40 | if duration > max_duration { 41 | return Err(anyhow!("Duration exceeds maximum of 1 week")); 42 | } 43 | 44 | if duration.as_secs() == 0 { 45 | return Err(anyhow!("Duration must be greater than 0")); 46 | } 47 | 48 | Ok(duration) 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUST_VERSION: 1.85.0 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: actions-rust-lang/setup-rust-toolchain@v1 19 | with: 20 | toolchain: ${{ env.RUST_VERSION }} 21 | # Required to build Katana at the moment. 22 | - uses: oven-sh/setup-bun@v1 23 | with: 24 | bun-version: latest 25 | - run: cargo test 26 | 27 | clippy: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v6 31 | - uses: actions-rust-lang/setup-rust-toolchain@v1 32 | with: 33 | toolchain: ${{ env.RUST_VERSION }} 34 | components: clippy 35 | # Required to build Katana at the moment. 36 | - uses: oven-sh/setup-bun@v1 37 | with: 38 | bun-version: latest 39 | - run: cargo clippy --all-targets --all-features 40 | 41 | fmt: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v6 45 | - uses: actions-rust-lang/setup-rust-toolchain@v1 46 | with: 47 | toolchain: ${{ env.RUST_VERSION }} 48 | components: rustfmt 49 | - uses: actions-rust-lang/rustfmt@v1 50 | 51 | ensure-windows: 52 | env: 53 | CMAKE_POLICY_VERSION_MINIMUM: "3.5" 54 | runs-on: windows-latest 55 | steps: 56 | - uses: actions/checkout@v6 57 | - uses: actions-rust-lang/setup-rust-toolchain@v1 58 | with: 59 | toolchain: ${{ env.RUST_VERSION }} 60 | target: x86_64-pc-windows-msvc 61 | # Required to build Katana at the moment. 62 | - uses: oven-sh/setup-bun@v1 63 | with: 64 | bun-version: latest 65 | - run: cargo check --target x86_64-pc-windows-msvc --workspace 66 | -------------------------------------------------------------------------------- /.claude/commands/commit.md: -------------------------------------------------------------------------------- 1 | # Smart Git Commit 2 | 3 | I'll analyze your changes and create a meaningful commit message. 4 | 5 | First, let me check what's changed: 6 | 7 | ```bash 8 | # Check if we have changes to commit 9 | if ! git diff --cached --quiet || ! git diff --quiet; then 10 | echo "Changes detected:" 11 | git status --short 12 | else 13 | echo "No changes to commit" 14 | exit 0 15 | fi 16 | 17 | # Show detailed changes 18 | git diff --cached --stat 19 | git diff --stat 20 | ``` 21 | 22 | Now I'll analyze the changes to determine: 23 | 24 | 1. What files were modified 25 | 2. The nature of changes (feature, fix, refactor, etc.) 26 | 3. The scope/component affected 27 | 28 | If the analysis or commit encounters errors: 29 | 30 | - I'll explain what went wrong 31 | - Suggest how to resolve it 32 | - Ensure no partial commits occur 33 | 34 | ```bash 35 | # If nothing is staged, I'll stage modified files (not untracked) 36 | if git diff --cached --quiet; then 37 | echo "No files staged. Staging modified files..." 38 | git add -u 39 | fi 40 | 41 | # Show what will be committed 42 | git diff --cached --name-status 43 | ``` 44 | 45 | Based on the analysis, I'll create a conventional commit message: 46 | 47 | - **Type**: feat|fix|docs|style|refactor|test|chore 48 | - **Scope**: component or area affected (optional) 49 | - **Subject**: clear description in present tense 50 | - **Body**: why the change was made (if needed) 51 | 52 | I will not add myself as co-author of the commit. 53 | 54 | Before committing, I'll run the `trunk check --ci --fix` command to ensure that 55 | the code is formatted correctly. If there are any errors, I'll ask the you to 56 | fix them manually. 57 | 58 | ```bash 59 | # I'll create the commit with the analyzed message 60 | # Example: git commit -m "fix(compiler): fix missing edge case in parser" 61 | ``` 62 | 63 | The commit message will be concise, meaningful, and follow your project's 64 | conventions if I can detect them from recent commits. 65 | -------------------------------------------------------------------------------- /scripts/test-capacity.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "creating..." 4 | 5 | total=100 6 | batch_size=10 7 | max_retries=3 8 | 9 | # Function to retry commands 10 | retry_command() { 11 | local cmd="$1" 12 | local retries=0 13 | 14 | while [ $retries -lt $max_retries ]; do 15 | if eval "$cmd"; then 16 | return 0 # Command succeeded 17 | else 18 | retries=$((retries + 1)) 19 | if [ $retries -eq $max_retries ]; then 20 | echo "Failed after $max_retries attempts: $cmd" 21 | return 1 22 | fi 23 | echo "Attempt $retries failed, retrying..." 24 | sleep 2 # Wait before retrying 25 | fi 26 | done 27 | } 28 | 29 | # Function to process create commands 30 | process_create_batch() { 31 | local start=$1 32 | local end=$2 33 | 34 | for i in $(seq $start $end); do 35 | cmd="slot d create --tier epic \"ls-tmpx-$i\" katana" 36 | retry_command "$cmd" & 37 | sleep 1 38 | done 39 | wait 40 | } 41 | 42 | # Function to process delete commands 43 | process_delete_batch() { 44 | local start=$1 45 | local end=$2 46 | 47 | for i in $(seq $start $end); do 48 | cmd="slot d delete \"ls-tmpx-$i\" katana -f" 49 | retry_command "$cmd" & 50 | sleep 1 51 | done 52 | wait 53 | } 54 | 55 | # Create deployments in batches 56 | for ((i=1; i<=total; i+=batch_size)); do 57 | end=$((i+batch_size-1)) 58 | if [ $end -gt $total ]; then 59 | end=$total 60 | fi 61 | echo "Processing create batch $i to $end..." 62 | process_create_batch $i $end 63 | done 64 | 65 | echo "success" 66 | 67 | echo "tearing down..." 68 | 69 | # Delete deployments in batches 70 | for ((i=1; i<=total; i+=batch_size)); do 71 | end=$((i+batch_size-1)) 72 | if [ $end -gt $total ]; then 73 | end=$total 74 | fi 75 | echo "Processing delete batch $i to $end..." 76 | process_delete_batch $i $end 77 | done 78 | 79 | echo "success" 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Slot 2 | 3 | ## Architecture 4 | 5 | The commands that Slot CLI offers must reflect what's the internal infrastructure is expecting. 6 | The current design is the following: 7 | 8 | 1. Slot CLI implements infra specific commands (like accounts, teams, etc..). Those ones are directly mapped to the infra specific code. 9 | 2. To use the services like Katana, Torii, etc.. Slot CLI must know the arguments that each service expects. For this, Slot CLI is using the `cli` crate for each service. 10 | 3. When creating or updating a service like Katana and Torii, Slot CLI will gather the arguments from the CLI and create a TOML configuration file for each service, which will ease the process of creating the services and passing arguments to the corresponding service. 11 | 12 | ## GraphQL 13 | 14 | Slot CLI is using a GraphQL API to interact with the Slot API (infrastructure). 15 | 16 | When you have to modify a GraphQL query, you must update the corresponding `.graphql` file and regenerate the Rust code by running: 17 | ```bash 18 | # Install the graphql-client CLI. 19 | # https://github.com/graphql-rust/graphql-client/blob/main/graphql_client_cli/README.md 20 | cargo install graphql-client 21 | 22 | # Pull the latest schema from the Slot API (or use the pull_schema.sh script) 23 | graphql-client introspect-schema --output slot/schema.json https://api.cartridge.gg/query 24 | 25 | # Then regenerate the Rust code for the queries that you have modified. 26 | # Using this command actually change the macro to something auto-generated. And we prefer macro expansion. 27 | # graphql-client generate --schema-path slot/schema.json slot/src/graphql/deployments/update.graphql 28 | ``` 29 | 30 | ## Local Development 31 | 32 | ### Pointing to a local API 33 | 34 | Run the cartridge api locally. 35 | 36 | Then set these variables in your slot directory: 37 | 38 | ```shell 39 | export CARTRIDGE_API_URL=http://localhost:8000 40 | export CARTRIDGE_KEYCHAIN_URL=http://localhost:3001 41 | ``` 42 | 43 | Then run `cargo run -- `. 44 | -------------------------------------------------------------------------------- /cli/src/command/rpc/tokens/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::rpc::create_rpc_api_key; 6 | use slot::graphql::rpc::CreateRpcApiKey; 7 | use slot::graphql::GraphQLQuery; 8 | 9 | #[derive(Debug, Args)] 10 | #[command(next_help_heading = "Create RPC token options")] 11 | pub struct CreateArgs { 12 | #[arg(help = "Name for the RPC token.")] 13 | name: String, 14 | 15 | #[arg(long, help = "Team name to associate the token with.")] 16 | team: String, 17 | } 18 | 19 | impl CreateArgs { 20 | pub async fn run(&self) -> Result<()> { 21 | let credentials = Credentials::load()?; 22 | 23 | let variables = create_rpc_api_key::Variables { 24 | team_name: self.team.clone(), 25 | name: self.name.clone(), 26 | }; 27 | let request_body = CreateRpcApiKey::build_query(variables); 28 | 29 | let client = Client::new_with_token(credentials.access_token); 30 | 31 | let data: create_rpc_api_key::ResponseData = client.query(&request_body).await?; 32 | 33 | println!("\n✅ RPC API Key Created Successfully"); 34 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 35 | 36 | println!("🔑 Details:"); 37 | println!(" • ID: {}", data.create_rpc_api_key.api_key.id); 38 | println!(" • Name: {}", data.create_rpc_api_key.api_key.name); 39 | println!(" • Team: {}", self.team); 40 | println!( 41 | " • Created: {}", 42 | data.create_rpc_api_key.api_key.created_at 43 | ); 44 | 45 | println!("\n🔐 Secret Key:"); 46 | println!(" • {}", data.create_rpc_api_key.secret_key); 47 | 48 | println!("\n⚠️ Important: Save this secret key securely - it won't be shown again!"); 49 | println!( 50 | "🔍 Key Prefix (for identification): {}", 51 | data.create_rpc_api_key.api_key.key_prefix 52 | ); 53 | 54 | Ok(()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cli/src/command/deployments/list.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::enum_variant_names)] 2 | 3 | use anyhow::Result; 4 | use clap::Args; 5 | 6 | use slot::graphql::deployments::list_deployments::{ResponseData, Variables}; 7 | use slot::graphql::deployments::ListDeployments; 8 | use slot::graphql::GraphQLQuery; 9 | use slot::{api::Client, credential::Credentials}; 10 | 11 | #[derive(Debug, Args)] 12 | #[command(next_help_heading = "List options")] 13 | pub struct ListArgs {} 14 | 15 | impl ListArgs { 16 | pub async fn run(&self) -> Result<()> { 17 | let request_body = ListDeployments::build_query(Variables {}); 18 | 19 | let user = Credentials::load()?; 20 | let client = Client::new_with_token(user.access_token); 21 | 22 | let data: ResponseData = client.query(&request_body).await?; 23 | if let Some(me) = data.me { 24 | if let Some(teams) = me.teams.edges { 25 | let teams: Vec<_> = teams 26 | .iter() 27 | .filter_map(|team| team.as_ref()) 28 | .filter_map(|team| team.node.as_ref()) 29 | .collect::<_>(); 30 | 31 | let deployments: Vec<_> = teams 32 | .iter() 33 | .filter_map(|team| team.deployments.edges.as_ref()) 34 | .flatten() 35 | .filter_map(|deployment| deployment.as_ref()) 36 | .filter(|deployment| { 37 | deployment 38 | .node 39 | .as_ref() 40 | .is_some_and(|node| format!("{:?}", node.status) != "deleted") 41 | }) 42 | .collect(); 43 | 44 | for deployment in deployments { 45 | println!("Project: {}", deployment.node.as_ref().unwrap().project); 46 | println!("Service: {}", deployment.node.as_ref().unwrap().service.id); 47 | println!("---"); 48 | } 49 | } 50 | } 51 | 52 | Ok(()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cli/src/command/teams/mod.rs: -------------------------------------------------------------------------------- 1 | use self::members::{TeamAddArgs, TeamListArgs, TeamRemoveArgs}; 2 | use crate::command::teams::create::CreateTeamArgs; 3 | use crate::command::teams::delete::DeleteTeamArgs; 4 | use crate::command::teams::invoices::InvoicesArgs; 5 | use crate::command::teams::update::UpdateTeamArgs; 6 | use anyhow::Result; 7 | use clap::{Args, Subcommand}; 8 | 9 | mod create; 10 | mod delete; 11 | mod invoices; 12 | mod members; 13 | mod update; 14 | 15 | #[derive(Debug, Args)] 16 | #[command(next_help_heading = "Team options")] 17 | pub struct Teams { 18 | #[arg(help = "The name of the team.")] 19 | pub name: String, 20 | 21 | #[command(subcommand)] 22 | teams_commands: TeamsCommands, 23 | } 24 | 25 | #[derive(Subcommand, Debug)] 26 | pub enum TeamsCommands { 27 | #[command(about = "Create a new team.")] 28 | Create(CreateTeamArgs), 29 | 30 | #[command(about = "Update an existing team.")] 31 | Update(UpdateTeamArgs), 32 | 33 | #[command(about = "Delete a team.")] 34 | Delete(DeleteTeamArgs), 35 | #[command(about = "List team members.", aliases = ["ls"])] 36 | List(TeamListArgs), 37 | 38 | #[command(about = "Add a new team member.")] 39 | Add(TeamAddArgs), 40 | 41 | #[command(about = "Remove a team member.")] 42 | Remove(TeamRemoveArgs), 43 | 44 | #[command(about = "List team invoices.")] 45 | Invoices(InvoicesArgs), 46 | } 47 | 48 | impl Teams { 49 | pub async fn run(&self) -> Result<()> { 50 | match &self.teams_commands { 51 | TeamsCommands::List(args) => args.run(self.name.clone()).await, 52 | TeamsCommands::Add(args) => args.run(self.name.clone()).await, 53 | TeamsCommands::Remove(args) => args.run(self.name.clone()).await, 54 | TeamsCommands::Create(args) => args.run(self.name.clone()).await, 55 | TeamsCommands::Update(args) => args.run(self.name.clone()).await, 56 | TeamsCommands::Delete(args) => args.run(self.name.clone()).await, 57 | TeamsCommands::Invoices(args) => args.run(self.name.clone()).await, 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cli/src/command/deployments/delete.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use dialoguer::theme::ColorfulTheme; 4 | use dialoguer::Confirm; 5 | use slot::graphql::deployments::{delete_deployment::*, DeleteDeployment}; 6 | use slot::graphql::GraphQLQuery; 7 | use slot::{api::Client, credential::Credentials}; 8 | 9 | #[derive(clap::ValueEnum, Clone, Debug, serde::Serialize)] 10 | pub enum Service { 11 | Katana, 12 | Torii, 13 | } 14 | 15 | #[derive(Debug, Args)] 16 | #[command(next_help_heading = "Delete options")] 17 | pub struct DeleteArgs { 18 | #[arg(help = "The name of the project.")] 19 | pub project: String, 20 | 21 | #[arg(help = "The name of the service.")] 22 | pub service: Service, 23 | 24 | #[arg(help = "Force delete without confirmation", short('f'))] 25 | pub force: bool, 26 | } 27 | 28 | impl DeleteArgs { 29 | pub async fn run(&self) -> Result<()> { 30 | if !self.force { 31 | let confirmation = Confirm::with_theme(&ColorfulTheme::default()) 32 | .with_prompt(format!( 33 | "Please confirm to delete {} {:?}", 34 | &self.project, &self.service 35 | )) 36 | .default(false) 37 | .show_default(true) 38 | .wait_for_newline(true) 39 | .interact() 40 | .unwrap(); 41 | 42 | if !confirmation { 43 | return Ok(()); 44 | } 45 | } 46 | 47 | let service = match &self.service { 48 | Service::Katana => DeploymentService::katana, 49 | Service::Torii => DeploymentService::torii, 50 | }; 51 | 52 | let request_body = DeleteDeployment::build_query(Variables { 53 | project: self.project.clone(), 54 | service, 55 | }); 56 | 57 | let user = Credentials::load()?; 58 | let client = Client::new_with_token(user.access_token); 59 | 60 | let _data: ResponseData = client.query(&request_body).await?; 61 | 62 | println!("Delete success 🚀"); 63 | 64 | Ok(()) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cli/src/command/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod deployments; 3 | pub mod merkle_drops; 4 | pub mod paymaster; 5 | pub mod paymasters; 6 | pub mod rpc; 7 | pub mod teams; 8 | 9 | use anyhow::Result; 10 | use clap::Subcommand; 11 | use slot::version; 12 | 13 | use auth::Auth; 14 | use deployments::Deployments; 15 | use merkle_drops::MerkleDropsCmd; 16 | use paymaster::PaymasterCmd; 17 | use paymasters::PaymastersCmd; 18 | use rpc::RpcCmd; 19 | use teams::Teams; 20 | 21 | #[allow(clippy::large_enum_variant)] 22 | #[derive(Subcommand, Debug)] 23 | pub enum Command { 24 | #[command(subcommand)] 25 | #[command(about = "Manage auth credentials for the Slot CLI.", aliases = ["a"])] 26 | Auth(Auth), 27 | 28 | #[command(subcommand)] 29 | #[command(about = "Manage Slot deployments.", aliases = ["d"])] 30 | Deployments(Deployments), 31 | 32 | #[command(about = "Manage Slot team.", aliases = ["t"])] 33 | Teams(Teams), 34 | 35 | #[command(subcommand)] 36 | #[command(about = "Manage merkle drops.", aliases = ["md"])] 37 | MerkleDrops(MerkleDropsCmd), 38 | 39 | #[command(subcommand)] 40 | #[command(about = "Manage paymasters.", aliases = ["ps"])] 41 | Paymasters(PaymastersCmd), 42 | 43 | #[command(about = "Operate on a specific paymaster.", aliases = ["p"])] 44 | Paymaster(PaymasterCmd), 45 | 46 | #[command(subcommand)] 47 | #[command(about = "Manage RPC tokens and configurations.", aliases = ["r"])] 48 | Rpc(RpcCmd), 49 | } 50 | 51 | impl Command { 52 | pub async fn run(&self) -> Result<()> { 53 | // Check for new version and run auto-update if available 54 | version::check_and_auto_update(); 55 | 56 | // Run the actual command 57 | match &self { 58 | Command::Auth(cmd) => cmd.run().await, 59 | Command::Deployments(cmd) => cmd.run().await, 60 | Command::Teams(cmd) => cmd.run().await, 61 | Command::MerkleDrops(cmd) => cmd.run().await, 62 | Command::Paymasters(cmd) => cmd.run().await, 63 | Command::Paymaster(cmd) => cmd.run().await, 64 | Command::Rpc(cmd) => cmd.run().await, 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Slot Cover Image](.github/cover.png) 2 | 3 | # Slot 4 | 5 | Slot is the execution layer of Dojo, supporting rapid provisioning of low latency, dedicated, provable execution contexts, bringing horizontal scalability to the blockchain. It manages the sequencing, proving, and efficient settlement of its execution. 6 | 7 | ## Installation 8 | 9 | Install `slotup` to manage slot installations and follow the outputted directions. 10 | ``` 11 | curl -L https://slot.cartridge.sh | bash 12 | ``` 13 | 14 | ## Usage 15 | 16 | Authenticate with Cartridge 17 | ```sh 18 | slot auth login 19 | ``` 20 | 21 | Create service deployments 22 | ```sh 23 | slot deployments create katana 24 | slot deployments create torii --world 0x3fa481f41522b90b3684ecfab7650c259a76387fab9c380b7a959e3d4ac69f 25 | ``` 26 | 27 | Update a service 28 | ```sh 29 | slot deployments update torii --version v0.3.5 30 | ``` 31 | 32 | Delete a service 33 | ```sh 34 | slot deployments delete torii 35 | ``` 36 | 37 | Read service logs 38 | ```sh 39 | slot deployments logs 40 | ``` 41 | 42 | List all deployments 43 | ```sh 44 | slot deployments list 45 | ``` 46 | 47 | View deployments configuration 48 | ```sh 49 | slot deployments describe 50 | ``` 51 | 52 | View predeployed accounts 53 | ```sh 54 | slot deployments accounts katana 55 | ``` 56 | 57 | Manage collaborators with teams 58 | ```sh 59 | slot teams list 60 | slot teams add 61 | slot teams remove 62 | ``` 63 | 64 | ## Environment Variables 65 | 66 | Slot CLI supports the following environment variables to control its behavior: 67 | 68 | | Variable | Description | 69 | |----------|-------------| 70 | | `SLOT_DISABLE_AUTO_UPDATE` | When set, disables automatic updates. The CLI will still check for updates and notify you, but won't attempt to update automatically. | 71 | | `SLOT_FORCE_AUTO_UPDATE` | When set, forces automatic updates without asking for confirmation. Useful for CI/CD environments. | 72 | | `CARTRIDGE_API_URL` | Override the default Cartridge API URL. | 73 | | `CARTRIDGE_KEYCHAIN_URL` | Override the default Cartridge Keychain URL. | 74 | -------------------------------------------------------------------------------- /cli/src/command/deployments/transfer.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use dialoguer::theme::ColorfulTheme; 4 | use dialoguer::Confirm; 5 | use slot::graphql::deployments::transfer_deployment::DeploymentService; 6 | use slot::graphql::deployments::{transfer_deployment::*, TransferDeployment}; 7 | use slot::graphql::GraphQLQuery; 8 | use slot::{api::Client, credential::Credentials}; 9 | 10 | #[derive(clap::ValueEnum, Clone, Debug, serde::Serialize)] 11 | pub enum Service { 12 | Katana, 13 | Torii, 14 | } 15 | 16 | #[derive(Debug, Args)] 17 | #[command(next_help_heading = "Transfer options")] 18 | pub struct TransferArgs { 19 | #[arg(help = "The name of the project.")] 20 | pub project: String, 21 | 22 | #[arg(help = "The name of the service.")] 23 | pub service: Service, 24 | 25 | #[arg(help = "The name of the team.")] 26 | pub team: String, 27 | 28 | #[arg(help = "Force Transfer without confirmation", short('f'))] 29 | pub force: bool, 30 | } 31 | 32 | impl TransferArgs { 33 | pub async fn run(&self) -> Result<()> { 34 | if !self.force { 35 | let confirmation = Confirm::with_theme(&ColorfulTheme::default()) 36 | .with_prompt(format!( 37 | "Please confirm to transfer {} {:?} to {}", 38 | &self.project, &self.service, &self.team 39 | )) 40 | .default(false) 41 | .show_default(true) 42 | .wait_for_newline(true) 43 | .interact() 44 | .unwrap(); 45 | 46 | if !confirmation { 47 | return Ok(()); 48 | } 49 | } 50 | 51 | let service = match &self.service { 52 | Service::Katana => DeploymentService::katana, 53 | Service::Torii => DeploymentService::torii, 54 | }; 55 | 56 | let request_body = TransferDeployment::build_query(Variables { 57 | name: self.project.clone(), 58 | team: self.team.clone(), 59 | service, 60 | }); 61 | 62 | let user = Credentials::load()?; 63 | let client = Client::new_with_token(user.access_token); 64 | 65 | let _data: ResponseData = client.query(&request_body).await?; 66 | 67 | println!("Transfer success 🚀"); 68 | 69 | Ok(()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /slot/src/graphql/rpc/mod.rs: -------------------------------------------------------------------------------- 1 | use graphql_client::GraphQLQuery; 2 | 3 | pub type Time = String; 4 | pub type Cursor = String; 5 | pub type BigInt = crate::bigint::BigInt; 6 | pub type Long = u64; 7 | 8 | // Mutation for creating an RPC API key 9 | #[derive(GraphQLQuery)] 10 | #[graphql( 11 | schema_path = "schema.json", 12 | query_path = "src/graphql/rpc/create_token.graphql", 13 | response_derives = "Debug, Serialize, Clone", 14 | variables_derives = "Debug" 15 | )] 16 | pub struct CreateRpcApiKey; 17 | 18 | // Mutation for deleting an RPC API key 19 | #[derive(GraphQLQuery)] 20 | #[graphql( 21 | schema_path = "schema.json", 22 | query_path = "src/graphql/rpc/delete_token.graphql", 23 | response_derives = "Debug, Serialize, Clone", 24 | variables_derives = "Debug" 25 | )] 26 | pub struct DeleteRpcApiKey; 27 | 28 | // Query for listing RPC API keys 29 | #[derive(GraphQLQuery)] 30 | #[graphql( 31 | schema_path = "schema.json", 32 | query_path = "src/graphql/rpc/list_tokens.graphql", 33 | response_derives = "Debug, Serialize, Clone", 34 | variables_derives = "Debug" 35 | )] 36 | pub struct ListRpcApiKeys; 37 | 38 | // Mutation for creating RPC CORS domain 39 | #[derive(GraphQLQuery)] 40 | #[graphql( 41 | schema_path = "schema.json", 42 | query_path = "src/graphql/rpc/add_whitelist_origin.graphql", 43 | response_derives = "Debug, Serialize, Clone", 44 | variables_derives = "Debug" 45 | )] 46 | pub struct CreateRpcCorsDomain; 47 | 48 | // Mutation for deleting RPC CORS domain 49 | #[derive(GraphQLQuery)] 50 | #[graphql( 51 | schema_path = "schema.json", 52 | query_path = "src/graphql/rpc/remove_whitelist_origin.graphql", 53 | response_derives = "Debug, Serialize, Clone", 54 | variables_derives = "Debug" 55 | )] 56 | pub struct DeleteRpcCorsDomain; 57 | 58 | // Query for listing RPC CORS domains 59 | #[derive(GraphQLQuery)] 60 | #[graphql( 61 | schema_path = "schema.json", 62 | query_path = "src/graphql/rpc/list_whitelist_origins.graphql", 63 | response_derives = "Debug, Serialize, Clone", 64 | variables_derives = "Debug" 65 | )] 66 | pub struct ListRpcCorsDomains; 67 | 68 | // Query for listing RPC logs 69 | #[derive(GraphQLQuery)] 70 | #[graphql( 71 | schema_path = "schema.json", 72 | query_path = "src/graphql/rpc/logs.graphql", 73 | response_derives = "Debug, Serialize, Clone", 74 | variables_derives = "Debug" 75 | )] 76 | pub struct ListRpcLogs; 77 | -------------------------------------------------------------------------------- /cli/src/command/deployments/services/katana.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Args; 4 | 5 | #[derive(Debug, Args, serde::Serialize)] 6 | #[command(next_help_heading = "Katana create options")] 7 | pub struct KatanaCreateArgs { 8 | #[arg(long, short = 'c')] 9 | #[arg( 10 | help = "Path to the Katana configuration file (TOML format). Required unless --optimistic is used." 11 | )] 12 | pub config: Option, 13 | 14 | #[arg(long, short, value_name = "provable mode")] 15 | #[arg(help = "Whether to run the service in provable mode.")] 16 | pub provable: bool, 17 | 18 | #[arg(long, short, value_name = "network")] 19 | #[arg(help = "Network to use for the service. Only in provable mode.")] 20 | pub network: Option, 21 | 22 | #[arg(long, short, value_name = "saya")] 23 | #[arg( 24 | help = "Whether to start a saya instance alongside the provable Katana. Only in provable mode." 25 | )] 26 | pub saya: bool, 27 | 28 | #[arg(long, short, value_name = "optimistic")] 29 | #[arg(help = "Whether to run the service in optimistic mode.")] 30 | pub optimistic: bool, 31 | 32 | #[arg(long, short = 'f', value_name = "fork_provider_url")] 33 | #[arg(help = "URL of the fork provider to use for the service.")] 34 | pub fork_provider_url: Option, 35 | } 36 | 37 | impl KatanaCreateArgs { 38 | pub fn validate(&self) -> anyhow::Result<()> { 39 | if self.config.is_none() && !self.optimistic { 40 | anyhow::bail!("Either --config or --optimistic must be provided"); 41 | } 42 | Ok(()) 43 | } 44 | } 45 | 46 | /// Update a Katana deployment. 47 | /// 48 | /// The main purpose of update is usually to change slot configuration (regions, tier, etc...), 49 | /// but it can also be used to change Katana parameters. 50 | /// For the latter, it is only possible using the configuration file (and not each individual parameter in the CLI), 51 | /// since the deployment has already been created with a configuration. 52 | #[derive(Debug, Args, serde::Serialize)] 53 | #[command(next_help_heading = "Katana update options")] 54 | pub struct KatanaUpdateArgs { 55 | #[arg(long)] 56 | #[arg( 57 | help = "The path to the configuration file to use for the update. This will replace the existing configuration." 58 | )] 59 | pub config: Option, 60 | } 61 | 62 | #[derive(Debug, Args, serde::Serialize)] 63 | #[command(next_help_heading = "Katana account options")] 64 | pub struct KatanaAccountArgs {} 65 | -------------------------------------------------------------------------------- /cli/src/command/rpc/whitelist/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; 4 | use slot::api::Client; 5 | use slot::credential::Credentials; 6 | use slot::graphql::rpc::list_rpc_cors_domains::{ResponseData, Variables}; 7 | use slot::graphql::rpc::ListRpcCorsDomains; 8 | use slot::graphql::GraphQLQuery; 9 | 10 | #[derive(Debug, Args)] 11 | #[command(next_help_heading = "List whitelist origins options")] 12 | pub struct ListArgs { 13 | #[arg(long, help = "Team name to list whitelist origins for.")] 14 | team: String, 15 | } 16 | 17 | impl ListArgs { 18 | pub async fn run(&self) -> Result<()> { 19 | let request_body = ListRpcCorsDomains::build_query(Variables { 20 | team_name: self.team.clone(), 21 | first: Some(100), 22 | after: None, 23 | where_: None, 24 | }); 25 | 26 | let user = Credentials::load()?; 27 | let client = Client::new_with_token(user.access_token); 28 | 29 | let data: ResponseData = client.query(&request_body).await?; 30 | 31 | if let Some(connection) = data.rpc_cors_domains { 32 | if let Some(edges) = connection.edges { 33 | let domains: Vec<_> = edges 34 | .iter() 35 | .filter_map(|edge| edge.as_ref()) 36 | .filter_map(|edge| edge.node.as_ref()) 37 | .collect(); 38 | 39 | if domains.is_empty() { 40 | println!("\nNo CORS domains found for team '{}'", self.team); 41 | return Ok(()); 42 | } 43 | 44 | let mut table = Table::new(); 45 | table 46 | .load_preset(UTF8_FULL) 47 | .set_content_arrangement(ContentArrangement::Dynamic) 48 | .set_header(vec![ 49 | Cell::new("ID"), 50 | Cell::new("Domain"), 51 | Cell::new("Created At"), 52 | ]); 53 | 54 | for domain in domains { 55 | table.add_row(vec![ 56 | Cell::new(&domain.id), 57 | Cell::new(&domain.domain), 58 | Cell::new(&domain.created_at), 59 | ]); 60 | } 61 | 62 | println!("\nCORS Domains for team '{}':", self.team); 63 | println!("{table}"); 64 | } 65 | } 66 | 67 | Ok(()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cli/src/command/deployments/describe.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::enum_variant_names)] 2 | 3 | use crate::command::deployments::print_config_file; 4 | 5 | use super::services::Service; 6 | use anyhow::Result; 7 | use clap::Args; 8 | use slot::graphql::deployments::{describe_deployment::*, DescribeDeployment}; 9 | use slot::graphql::GraphQLQuery; 10 | use slot::{api::Client, credential::Credentials}; 11 | 12 | #[derive(Debug, Args)] 13 | #[command(next_help_heading = "Describe options")] 14 | pub struct DescribeArgs { 15 | #[arg(help = "The project of the project.")] 16 | pub project: String, 17 | 18 | #[arg(help = "The service of the project.")] 19 | pub service: Service, 20 | } 21 | 22 | impl DescribeArgs { 23 | pub async fn run(&self) -> Result<()> { 24 | let service = match self.service { 25 | Service::Torii => DeploymentService::torii, 26 | Service::Katana => DeploymentService::katana, 27 | }; 28 | 29 | let request_body = DescribeDeployment::build_query(Variables { 30 | project: self.project.clone(), 31 | service, 32 | }); 33 | 34 | let user = Credentials::load()?; 35 | let client = Client::new_with_token(user.access_token); 36 | 37 | let data: ResponseData = client.query(&request_body).await?; 38 | 39 | if let Some(deployment) = data.deployment { 40 | println!("Project: {}", deployment.project); 41 | println!("Version: {}", deployment.version); 42 | 43 | if deployment.deprecated.unwrap_or(false) { 44 | println!(); 45 | println!("NOTE:"); 46 | println!("This deployment is deprecated and immutable."); 47 | println!("Please delete it and re-create. Note that this will reset the storage."); 48 | println!(); 49 | } 50 | 51 | println!( 52 | "Branch: {}", 53 | deployment.branch.unwrap_or_else(|| String::from("Default")) 54 | ); 55 | println!("Tier: {:?}", deployment.tier); 56 | 57 | println!( 58 | "Url: {}", 59 | super::service_url(&deployment.project, &self.service.to_string()) 60 | ); 61 | 62 | // convert config of type String to &str 63 | print_config_file(&deployment.config.config_file); 64 | 65 | if deployment.error.is_some() { 66 | println!("\n─────────────── ERROR INFO ───────────────"); 67 | println!("Error: {}", deployment.error.unwrap()); 68 | println!("\n─────────────── ERROR INFO ───────────────"); 69 | } 70 | } 71 | 72 | Ok(()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/update.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::paymaster::update_paymaster; 6 | use slot::graphql::paymaster::UpdatePaymaster; 7 | use slot::graphql::GraphQLQuery; 8 | 9 | #[derive(Debug, Args)] 10 | #[command(next_help_heading = "Update paymaster options")] 11 | pub struct UpdateArgs { 12 | #[arg(long, help = "New name for the paymaster.")] 13 | name: Option, 14 | #[arg(long, help = "New team name to associate the paymaster with.")] 15 | team: Option, 16 | #[arg(long, help = "Set the active state of the paymaster.")] 17 | active: Option, 18 | } 19 | 20 | impl UpdateArgs { 21 | pub async fn run(&self, current_name: String) -> Result<()> { 22 | // Check if any update parameters are provided 23 | if self.name.is_none() && self.team.is_none() && self.active.is_none() { 24 | return Err(anyhow::anyhow!( 25 | "No update parameters provided. Use --name, --team, or --active to specify what to update." 26 | )); 27 | } 28 | 29 | // Proceed with the update 30 | let credentials = Credentials::load()?; 31 | 32 | let variables = update_paymaster::Variables { 33 | paymaster_name: current_name.clone(), 34 | new_name: self.name.clone(), 35 | team_name: self.team.clone(), 36 | active: self.active, 37 | }; 38 | let request_body = UpdatePaymaster::build_query(variables); 39 | 40 | let client = Client::new_with_token(credentials.access_token); 41 | 42 | let _data: update_paymaster::ResponseData = client.query(&request_body).await?; 43 | 44 | // Display success message 45 | println!("\n✅ Paymaster Updated Successfully"); 46 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 47 | 48 | println!( 49 | "🏢 Updated paymaster: {}", 50 | self.name.as_ref().unwrap_or(¤t_name) 51 | ); 52 | 53 | println!("\n🔧 Applied changes:"); 54 | if let Some(ref new_name) = self.name { 55 | println!(" • Name updated to: {}", new_name); 56 | } 57 | 58 | if let Some(ref new_team) = self.team { 59 | println!(" • Team updated to: {}", new_team); 60 | } 61 | 62 | if let Some(active_state) = self.active { 63 | println!( 64 | " • Active state updated to: {}", 65 | if active_state { 66 | "✅ Active" 67 | } else { 68 | "❌ Inactive" 69 | } 70 | ); 71 | } 72 | 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /cli/src/command/auth/transfer.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::graphql::auth::{transfer::*, Transfer}; 4 | use slot::graphql::GraphQLQuery; 5 | use slot::{api::Client, credential::Credentials}; 6 | 7 | #[derive(Debug, Args)] 8 | #[command(next_help_heading = "Transfer")] 9 | pub struct TransferArgs { 10 | #[arg(help = "The team name to transfer funds to.", value_name = "team")] 11 | pub team: String, 12 | 13 | #[arg(long, help = "The USD amount to transfer", value_name = "USD")] 14 | pub usd: Option, 15 | 16 | #[arg(long, help = "The credits amount to transfer", value_name = "CREDITS")] 17 | pub credits: Option, 18 | } 19 | 20 | pub fn get_amount(usd: Option, credits: Option) -> Result { 21 | match (usd, credits) { 22 | (Some(usd_amount), None) => Ok(usd_amount * 100), 23 | (None, Some(credits_amount)) => Ok(credits_amount), 24 | (None, None) => Err(anyhow::anyhow!( 25 | "Either --usd or --credits must be specified" 26 | )), 27 | (Some(_), Some(_)) => Err(anyhow::anyhow!("Cannot specify both --usd and --credits")), 28 | } 29 | } 30 | 31 | impl TransferArgs { 32 | pub async fn run(&self) -> Result<()> { 33 | let credentials = Credentials::load()?; 34 | let client = Client::new_with_token(credentials.access_token); 35 | 36 | let amount = get_amount(self.usd, self.credits)?; 37 | 38 | let request_body = Transfer::build_query(Variables { 39 | transfer: TransferInput { 40 | amount, 41 | team: self.team.clone(), 42 | }, 43 | }); 44 | let res: ResponseData = client.query(&request_body).await?; 45 | 46 | // Display the appropriate amount in the message 47 | let amount_display = if let Some(usd) = self.usd { 48 | format!("${} USD", usd) 49 | } else if let Some(credits) = self.credits { 50 | format!("{} credits", credits) 51 | } else { 52 | // This shouldn't happen due to the error check in get_amount 53 | "amount".to_string() 54 | }; 55 | 56 | println!("Transferred {} to {}", amount_display, self.team); 57 | println!( 58 | "User balance: {} credits (~${} USD) -> {} credits (~${} USD)", 59 | res.transfer.account_before, 60 | res.transfer.account_before / 100, 61 | res.transfer.account_after, 62 | res.transfer.account_after / 100 63 | ); 64 | println!( 65 | "Team balance: {} credits (~${} USD) -> {} credits (~${} USD)", 66 | res.transfer.team_before, 67 | res.transfer.team_before / 100, 68 | res.transfer.team_after, 69 | res.transfer.team_after / 100 70 | ); 71 | 72 | Ok(()) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/create.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result}; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::paymaster::create_paymaster; 6 | use slot::graphql::paymaster::create_paymaster::FeeUnit; 7 | use slot::graphql::paymaster::CreatePaymaster; 8 | use slot::graphql::GraphQLQuery; 9 | 10 | #[derive(Debug, Args)] 11 | #[command( 12 | next_help_heading = "Create paymaster options", 13 | after_help = "Examples:\n slot paymaster my-paymaster create --team my-team --budget 10 --unit usd" 14 | )] 15 | pub struct CreateArgs { 16 | #[arg(long, help = "Team name to associate the paymaster with.")] 17 | team: String, 18 | #[arg(long, help = "Initial budget for the paymaster.")] 19 | budget: u64, 20 | #[arg(long, help = "Unit for the budget (USD or STRK).")] 21 | unit: String, 22 | } 23 | 24 | impl CreateArgs { 25 | pub async fn run(&self, name: String) -> Result<()> { 26 | let credentials = Credentials::load()?; 27 | 28 | let (unit, budget_for_api) = match self.unit.to_uppercase().as_str() { 29 | "USD" => (FeeUnit::CREDIT, (self.budget * 100) as i64), // Convert USD to credits 30 | "STRK" => (FeeUnit::STRK, self.budget as i64), 31 | _ => { 32 | return Err(anyhow::anyhow!( 33 | "Invalid unit: {}. Supported units: USD, STRK", 34 | self.unit 35 | )) 36 | } 37 | }; 38 | 39 | let variables = create_paymaster::Variables { 40 | name: name.clone(), 41 | team_name: self.team.clone(), 42 | budget: budget_for_api, 43 | unit, 44 | }; 45 | let request_body = CreatePaymaster::build_query(variables); 46 | 47 | let client = Client::new_with_token(credentials.access_token); 48 | 49 | let data: create_paymaster::ResponseData = client.query(&request_body).await?; 50 | 51 | let budget_formatted = data.create_paymaster.budget as f64 / 1e6; 52 | 53 | // Calculate display values based on original unit 54 | let display_budget = match self.unit.to_uppercase().as_str() { 55 | "USD" => format!("${:.2} USD", budget_formatted * 0.01), // Convert credits back to USD for display 56 | "STRK" => format!("{} STRK", budget_formatted as i64), 57 | _ => format!("{} {}", budget_formatted as i64, self.unit.to_uppercase()), 58 | }; 59 | 60 | println!("\n✅ Paymaster Created Successfully"); 61 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 62 | 63 | println!("🏢 Details:"); 64 | println!(" • Name: {}", data.create_paymaster.name); 65 | println!(" • Team: {}", self.team); 66 | 67 | println!("\n💰 Initial Budget:"); 68 | println!(" • Amount: {}", display_budget); 69 | 70 | Ok(()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cli/src/command/rpc/tokens/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; 4 | use slot::api::Client; 5 | use slot::credential::Credentials; 6 | use slot::graphql::rpc::list_rpc_api_keys::{ResponseData, Variables}; 7 | use slot::graphql::rpc::ListRpcApiKeys; 8 | use slot::graphql::GraphQLQuery; 9 | 10 | #[derive(Debug, Args)] 11 | #[command(next_help_heading = "List RPC tokens options")] 12 | pub struct ListArgs { 13 | #[arg(long, help = "Team name to list tokens for.")] 14 | team: String, 15 | } 16 | 17 | impl ListArgs { 18 | pub async fn run(&self) -> Result<()> { 19 | let request_body = ListRpcApiKeys::build_query(Variables { 20 | team_name: self.team.clone(), 21 | first: Some(100), 22 | after: None, 23 | where_: None, 24 | }); 25 | 26 | let user = Credentials::load()?; 27 | let client = Client::new_with_token(user.access_token); 28 | 29 | let data: ResponseData = client.query(&request_body).await?; 30 | 31 | if let Some(connection) = data.rpc_api_keys { 32 | if let Some(edges) = connection.edges { 33 | let tokens: Vec<_> = edges 34 | .iter() 35 | .filter_map(|edge| edge.as_ref()) 36 | .filter_map(|edge| edge.node.as_ref()) 37 | .collect(); 38 | 39 | if tokens.is_empty() { 40 | println!("\nNo RPC API keys found for team '{}'", self.team); 41 | return Ok(()); 42 | } 43 | 44 | let mut table = Table::new(); 45 | table 46 | .load_preset(UTF8_FULL) 47 | .set_content_arrangement(ContentArrangement::Dynamic) 48 | .set_header(vec![ 49 | Cell::new("ID"), 50 | Cell::new("Name"), 51 | Cell::new("Key Prefix"), 52 | Cell::new("Active"), 53 | Cell::new("Created At"), 54 | Cell::new("Last Used"), 55 | ]); 56 | 57 | for token in tokens { 58 | table.add_row(vec![ 59 | Cell::new(&token.id), 60 | Cell::new(&token.name), 61 | Cell::new(&token.key_prefix), 62 | Cell::new(if token.active { "✓" } else { "✗" }), 63 | Cell::new(&token.created_at), 64 | Cell::new(token.last_used_at.as_ref().map_or("-", |s| s.as_str())), 65 | ]); 66 | } 67 | 68 | println!("\nRPC API Keys for team '{}':", self.team); 69 | println!("{table}"); 70 | } 71 | } 72 | 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /slot/src/server.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::net::{SocketAddr, TcpListener}; 3 | 4 | use axum::Router; 5 | use tokio::sync::mpsc::Receiver; 6 | use tower_http::cors::CorsLayer; 7 | use tower_http::trace::TraceLayer; 8 | 9 | /// A simple local server. 10 | #[derive(Debug)] 11 | pub struct LocalServer { 12 | router: Router, 13 | listener: TcpListener, 14 | shutdown_rx: Option>, 15 | } 16 | 17 | impl LocalServer { 18 | pub fn new(router: Router) -> anyhow::Result { 19 | // Port number of 0 requests OS to find an available port. 20 | let listener = TcpListener::bind("localhost:0")?; 21 | listener.set_nonblocking(true)?; // !important 22 | 23 | // To view the logs emitted by the server, set `RUST_LOG=tower_http=trace` 24 | let router = router.layer(TraceLayer::new_for_http()); 25 | 26 | Ok(Self { 27 | router, 28 | listener, 29 | shutdown_rx: None, 30 | }) 31 | } 32 | 33 | /// Add a CORS layer to the server. 34 | pub fn cors(mut self, cors: CorsLayer) -> Self { 35 | self.router = self.router.layer(cors); 36 | self 37 | } 38 | 39 | /// Shutdown the server when a signal is received from `receiver`. 40 | pub fn with_shutdown_signal(mut self, receiver: Receiver<()>) -> Self { 41 | self.shutdown_rx = Some(receiver); 42 | self 43 | } 44 | 45 | pub fn local_addr(&self) -> Result { 46 | self.listener.local_addr() 47 | } 48 | 49 | pub async fn start(mut self) -> anyhow::Result<()> { 50 | let addr = self.listener.local_addr()?; 51 | tracing::info!(?addr, "Callback server started"); 52 | 53 | let listener = tokio::net::TcpListener::from_std(self.listener)?; 54 | let server = axum::serve(listener, self.router.into_make_service()); 55 | 56 | if let Some(mut rx) = self.shutdown_rx.take() { 57 | server 58 | .with_graceful_shutdown(async move { rx.recv().await.expect("channel closed") }) 59 | .await?; 60 | } else { 61 | server.await?; 62 | } 63 | 64 | Ok(()) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use crate::server::LocalServer; 71 | use axum::{routing::get, Router}; 72 | 73 | #[tokio::test] 74 | async fn test_server_graceful_shutdown() { 75 | let (tx, rx) = tokio::sync::mpsc::channel(1); 76 | 77 | let router = Router::new().route("/callback", get(|| async { "Hello, World!" })); 78 | let server = LocalServer::new(router).unwrap().with_shutdown_signal(rx); 79 | let port = server.local_addr().unwrap().port(); 80 | 81 | let client = reqwest::Client::new(); 82 | let url = format!("http://localhost:{port}/callback"); 83 | 84 | // start the local server 85 | tokio::spawn(server.start()); 86 | 87 | // first request should succeed 88 | assert!(client.get(&url).send().await.is_ok()); 89 | 90 | // send shutdown signal 91 | tx.send(()).await.unwrap(); 92 | 93 | // sending request after sending the shutdown signal should fail as server 94 | // should've been shutdown 95 | assert!(client.get(url).send().await.is_err()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /cli/src/command/teams/members.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::team::{ 6 | team_member_add, team_member_remove, team_members_list, TeamMemberAdd, TeamMemberRemove, 7 | TeamMembersList, 8 | }; 9 | use slot::graphql::GraphQLQuery; 10 | 11 | #[derive(Debug, Args, serde::Serialize)] 12 | #[command(next_help_heading = "Team list options")] 13 | pub struct TeamListArgs; 14 | 15 | impl TeamListArgs { 16 | pub async fn run(&self, team: String) -> Result<()> { 17 | let request_body = 18 | TeamMembersList::build_query(team_members_list::Variables { team: team.clone() }); 19 | 20 | let user = Credentials::load()?; 21 | let client = Client::new_with_token(user.access_token); 22 | 23 | let data: team_members_list::ResponseData = client.query(&request_body).await?; 24 | 25 | if let Some(team_list) = data.team { 26 | if team_list.deleted { 27 | println!("Team '{}' not found or has been deleted", team); 28 | return Ok(()); 29 | } 30 | 31 | team_list 32 | .members 33 | .edges 34 | .into_iter() 35 | .flatten() 36 | .for_each(|edge| { 37 | if let Some(node) = edge.and_then(|edge| edge.node) { 38 | println!(" {}", node.id) 39 | } 40 | }); 41 | } 42 | 43 | Ok(()) 44 | } 45 | } 46 | 47 | #[derive(Debug, Args, serde::Serialize)] 48 | #[command(next_help_heading = "Team add options")] 49 | pub struct TeamAddArgs { 50 | #[arg(help = "Name of the team member to add.")] 51 | pub account: Vec, 52 | } 53 | 54 | impl TeamAddArgs { 55 | pub async fn run(&self, team: String) -> Result<()> { 56 | let request_body = TeamMemberAdd::build_query(team_member_add::Variables { 57 | team, 58 | accounts: self.account.clone(), 59 | }); 60 | 61 | let user = Credentials::load()?; 62 | let client = Client::new_with_token(user.access_token); 63 | 64 | let _data: team_member_add::ResponseData = client.query(&request_body).await?; 65 | 66 | println!("Successfully added {} to the team", self.account.join(", ")); 67 | 68 | Ok(()) 69 | } 70 | } 71 | 72 | #[derive(Debug, Args, serde::Serialize)] 73 | #[command(next_help_heading = "Team remove options")] 74 | pub struct TeamRemoveArgs { 75 | #[arg(help = "Name of the team member to add.")] 76 | pub account: Vec, 77 | } 78 | 79 | impl TeamRemoveArgs { 80 | pub async fn run(&self, team: String) -> Result<()> { 81 | let request_body = TeamMemberRemove::build_query(team_member_remove::Variables { 82 | team: team.clone(), 83 | accounts: self.account.clone(), 84 | }); 85 | 86 | let user = Credentials::load()?; 87 | let client = Client::new_with_token(user.access_token); 88 | 89 | let _data: team_member_remove::ResponseData = client.query(&request_body).await?; 90 | 91 | println!( 92 | "Successfully removed {} from the team {}", 93 | self.account.join(", "), 94 | team, 95 | ); 96 | 97 | Ok(()) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Slot is a CLI tool and execution layer for the Dojo ecosystem that manages rapid provisioning of low-latency, dedicated, provable execution contexts. It provides horizontal scalability to blockchain applications through managed sequencing, proving, and settlement. 8 | 9 | ## Tech Stack 10 | 11 | - **Language**: Rust (workspace with two crates: `slot-cli` and `slot`) 12 | - **Web Framework**: Axum for HTTP services 13 | - **GraphQL**: Comprehensive GraphQL client integration with generated code 14 | - **Blockchain**: Starknet SDK, Cartridge Controller Account SDK 15 | - **External Services**: Katana (sequencer) and Torii (indexer) from Dojo ecosystem 16 | 17 | ## Development Commands 18 | 19 | ### Core Development Tasks 20 | ```bash 21 | # Linting (strict - warnings treated as errors) 22 | ./scripts/clippy.sh 23 | 24 | # Code formatting check 25 | ./scripts/rust_fmt.sh 26 | 27 | # Code formatting fix 28 | ./scripts/rust_fmt_fix.sh 29 | 30 | # End-to-end testing (creates/tests/deletes deployments) 31 | ./scripts/e2e.sh 32 | 33 | # Update GraphQL schema from API 34 | ./scripts/pull_schema.sh 35 | ``` 36 | 37 | ### Build Commands 38 | ```bash 39 | # Development build 40 | cargo build 41 | 42 | # Release build 43 | cargo build --release 44 | 45 | # Run CLI locally 46 | cargo run -- 47 | ``` 48 | 49 | ### Testing 50 | - E2E tests create temporary deployments, test functionality, and clean up 51 | - Tests verify both Katana and Torii service deployments 52 | - Health checks ensure services are responding before proceeding 53 | 54 | ## Architecture 55 | 56 | ### Command Structure 57 | The CLI is organized into main command groups: 58 | - `slot auth` - Authentication and session management 59 | - `slot deployments` (alias: `d`) - Katana/Torii service management 60 | - `slot teams` - Team collaboration features 61 | - `slot paymaster`/`slot paymasters` - Gas fee management 62 | 63 | ### Core Components 64 | - **CLI Layer** (`cli/src/command/`) - Command implementations organized by domain 65 | - **Core Library** (`slot/src/`) - Shared functionality including GraphQL client, account management, API client 66 | - **GraphQL Integration** - Generated client code from comprehensive schema (37k+ lines) 67 | - **Service Management** - Configuration-driven deployment of Katana/Torii services 68 | 69 | ### Key Files 70 | - `slot/src/graphql/schema.graphql` - GraphQL schema (regenerate Rust code after changes) 71 | - `cli/src/command/` - Command implementations 72 | - `slot/src/api.rs` - Core API client with authentication 73 | 74 | ## Environment Variables 75 | - `SLOT_DISABLE_AUTO_UPDATE` - Disable automatic updates 76 | - `SLOT_FORCE_AUTO_UPDATE` - Force updates without confirmation 77 | - `CARTRIDGE_API_URL` - Override API endpoint 78 | - `CARTRIDGE_KEYCHAIN_URL` - Override keychain endpoint 79 | 80 | ## Development Notes 81 | 82 | ### Service Deployments 83 | - Katana: Starknet sequencer with configurable block times and account predeployment 84 | - Torii: Indexer requiring world address configuration 85 | - Both support tier scaling (basic to epic) for performance requirements 86 | 87 | ### Authentication 88 | All API operations require authentication via `slot auth login` which handles Cartridge Controller integration. 89 | 90 | ## Installation & Distribution 91 | - Users install via `curl -L https://slot.cartridge.sh | bash` 92 | - Auto-update mechanism built into CLI 93 | - Multi-stage Docker build for containerized deployment 94 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/stats.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use chrono::{DateTime, Utc}; 3 | use clap::Args; 4 | use slot::api::Client; 5 | use slot::credential::Credentials; 6 | use slot::graphql::paymaster::paymaster_stats; 7 | use slot::graphql::paymaster::PaymasterStats; 8 | use slot::graphql::GraphQLQuery; 9 | use std::time::{SystemTime, UNIX_EPOCH}; 10 | 11 | use super::utils; 12 | 13 | #[derive(Debug, Args)] 14 | #[command(next_help_heading = "Paymaster stats options")] 15 | pub struct StatsArgs { 16 | #[arg( 17 | long, 18 | help = "Time period to look back (e.g., 1hr, 2min, 24hr, 1day, 1week). Default is 24hr.", 19 | default_value = "24hr" 20 | )] 21 | last: String, 22 | } 23 | 24 | impl StatsArgs { 25 | pub async fn run(&self, name: String) -> Result<()> { 26 | // 1. Load Credentials 27 | let credentials = Credentials::load()?; 28 | 29 | // 2. Parse the time duration 30 | let duration = utils::parse_duration(&self.last)?; 31 | 32 | // 3. Calculate the "since" timestamp 33 | let now = SystemTime::now(); 34 | let since_time = now - duration; 35 | let since_timestamp = since_time 36 | .duration_since(UNIX_EPOCH) 37 | .map_err(|_| anyhow!("Invalid time calculation"))? 38 | .as_secs(); 39 | 40 | // 4. Convert to RFC3339 format 41 | let since_rfc3339 = DateTime::::from_timestamp(since_timestamp as i64, 0) 42 | .ok_or_else(|| anyhow!("Invalid timestamp"))? 43 | .to_rfc3339(); 44 | 45 | // 5. Build Query Variables 46 | let variables = paymaster_stats::Variables { 47 | paymaster_name: name.clone(), 48 | since: since_rfc3339, 49 | }; 50 | 51 | let request_body = PaymasterStats::build_query(variables); 52 | 53 | // 6. Create Client 54 | let client = Client::new_with_token(credentials.access_token); 55 | 56 | let data: paymaster_stats::ResponseData = client.query(&request_body).await?; 57 | 58 | // 8. Print Results 59 | let stats = &data.paymaster_stats; 60 | 61 | println!("\n📊 Paymaster Stats for '{}' (Last {})", name, self.last); 62 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 63 | println!("📈 Transactions:"); 64 | println!(" • Total: {}", stats.total_transactions); 65 | println!(" • Successful: {}", stats.successful_transactions); 66 | println!(" • Reverted: {}", stats.reverted_transactions); 67 | 68 | if stats.total_transactions > 0 { 69 | let success_rate = 70 | (stats.successful_transactions as f64 / stats.total_transactions as f64) * 100.0; 71 | println!(" • Success Rate: {:.1}%", success_rate); 72 | 73 | // Calculate TPS 74 | let duration_seconds = duration.as_secs() as f64; 75 | let tps = stats.total_transactions as f64 / duration_seconds; 76 | println!(" • TPS: {:.4}", tps); 77 | } 78 | 79 | println!("\n💰 Fees (USD):"); 80 | println!( 81 | " • Total ({}): ${:.2}", 82 | self.last, 83 | stats.total_usd_fees.unwrap_or(0.0) 84 | ); 85 | println!(" • Average: ${:.6}", stats.avg_usd_fee.unwrap_or(0.0)); 86 | println!(" • Minimum: ${:.6}", stats.min_usd_fee.unwrap_or(0.0)); 87 | println!(" • Maximum: ${:.6}", stats.max_usd_fee.unwrap_or(0.0)); 88 | 89 | println!("\n👥 Users:"); 90 | println!(" • Unique Users: {}", stats.unique_users); 91 | 92 | Ok(()) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /slot/src/utils.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use std::{fs, path::PathBuf, sync::OnceLock}; 3 | 4 | /// The default directory name where the Slot-generated files (e.g credentials/session keys) are stored. 5 | const SLOT_DIR: &str = "slot"; 6 | 7 | /// Static instance of the email validation regex, compiled once on first use. 8 | static EMAIL_REGEX: OnceLock = OnceLock::new(); 9 | 10 | /// Get the path to the config directory where the Slot-generated files (e.g credentials/session keys) are stored. 11 | /// This function guarantees that the config directory exists. 12 | /// 13 | /// If this function is called in a test environment, path to a temporary directory is returned instead. 14 | pub fn config_dir() -> PathBuf { 15 | let path = if cfg!(test) { 16 | tempfile::tempdir().unwrap().keep() 17 | } else { 18 | dirs::config_local_dir().expect("unsupported OS") 19 | } 20 | .join(SLOT_DIR); 21 | 22 | if path.exists() { 23 | path 24 | } else { 25 | fs::create_dir_all(&path).expect("failed to create config directory"); 26 | path 27 | } 28 | } 29 | 30 | /// Validates if the provided string is a valid email address format. 31 | /// 32 | /// Uses a regex pattern to check for basic email format: 33 | /// - Local part: alphanumeric characters, dots, hyphens, underscores 34 | /// - @ symbol 35 | /// - Domain part: alphanumeric characters, dots, hyphens 36 | /// - At least one dot in domain part 37 | /// 38 | /// # Arguments 39 | /// * `email` - The email string to validate 40 | /// 41 | /// # Returns 42 | /// * `true` if the email format is valid, `false` otherwise 43 | pub fn is_valid_email(email: &str) -> bool { 44 | let regex = EMAIL_REGEX.get_or_init(|| { 45 | Regex::new(r"^[a-zA-Z0-9]([a-zA-Z0-9._%+-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}$").unwrap() 46 | }); 47 | regex.is_match(email) 48 | && !email.contains("..") 49 | && !email.starts_with('.') 50 | && !email.ends_with('.') 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use crate::utils::SLOT_DIR; 56 | 57 | #[test] 58 | fn config_dir_must_exist() { 59 | let path = super::config_dir(); 60 | assert!(path.exists()); 61 | assert!(path.ends_with(SLOT_DIR)); 62 | } 63 | 64 | #[test] 65 | fn test_valid_emails() { 66 | assert!(super::is_valid_email("test@example.com")); 67 | assert!(super::is_valid_email("user.name@domain.co.uk")); 68 | assert!(super::is_valid_email("firstname+lastname@example.org")); 69 | assert!(super::is_valid_email("test_email@sub.domain.com")); 70 | } 71 | 72 | #[test] 73 | fn test_invalid_emails() { 74 | assert!(!super::is_valid_email("invalid-email")); 75 | assert!(!super::is_valid_email("@example.com")); 76 | assert!(!super::is_valid_email("test@")); 77 | assert!(!super::is_valid_email("test@.com")); 78 | assert!(!super::is_valid_email("test@domain")); 79 | assert!(!super::is_valid_email("")); 80 | assert!(!super::is_valid_email("test..email@example.com")); 81 | } 82 | 83 | #[test] 84 | fn test_edge_case_emails() { 85 | // Single character local/domain parts 86 | assert!(super::is_valid_email("a@b.com")); 87 | assert!(super::is_valid_email("x@example.co")); 88 | 89 | // Valid special characters 90 | assert!(super::is_valid_email("test-email@example.com")); 91 | assert!(super::is_valid_email("user_name@sub-domain.com")); 92 | 93 | // Domain with numbers 94 | assert!(super::is_valid_email("test@123domain.com")); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /slot/src/graphql/auth/mod.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use graphql_client::GraphQLQuery; 4 | use me::MeMe; 5 | use starknet::core::types::Felt; 6 | 7 | use crate::account::{self}; 8 | 9 | #[derive(GraphQLQuery)] 10 | #[graphql( 11 | schema_path = "schema.json", 12 | query_path = "src/graphql/auth/info.graphql", 13 | response_derives = "Debug, Clone, Serialize, PartialEq, Eq" 14 | )] 15 | pub struct Me; 16 | 17 | #[derive(GraphQLQuery)] 18 | #[graphql( 19 | schema_path = "schema.json", 20 | query_path = "src/graphql/auth/update-me.graphql", 21 | response_derives = "Debug, Clone, Serialize, PartialEq, Eq" 22 | )] 23 | pub struct UpdateMe; 24 | 25 | #[derive(GraphQLQuery)] 26 | #[graphql( 27 | schema_path = "schema.json", 28 | query_path = "src/graphql/auth/transfer.graphql", 29 | response_derives = "Debug, Clone, Serialize" 30 | )] 31 | pub struct Transfer; 32 | 33 | impl From for account::AccountInfo { 34 | fn from(value: MeMe) -> Self { 35 | let id = value.id; 36 | let username = value.username; 37 | let credentials = value.credentials.webauthn.unwrap_or_default(); 38 | let controllers = value 39 | .controllers 40 | .edges 41 | .unwrap_or_default() 42 | .into_iter() 43 | .map(|c| c.unwrap()) 44 | .map(account::Controller::from) 45 | .collect(); 46 | 47 | Self { 48 | id, 49 | username, 50 | controllers, 51 | credentials, 52 | } 53 | } 54 | } 55 | 56 | impl From for account::Controller { 57 | fn from(value: me::MeMeControllersEdges) -> Self { 58 | let node = value.node.unwrap(); 59 | let id = node.id; 60 | let address = Felt::from_str(&node.address).expect("valid address"); 61 | 62 | Self { id, address } 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use crate::account::AccountInfo; 69 | 70 | #[allow(unused_imports)] 71 | use super::*; 72 | 73 | #[test] 74 | fn test_try_from_me() { 75 | let me = MeMe { 76 | id: "id".to_string(), 77 | username: "username".to_string(), 78 | credentials: me::MeMeCredentials { 79 | webauthn: Some(vec![me::MeMeCredentialsWebauthn { 80 | id: "id".to_string(), 81 | public_key: "foo".to_string(), 82 | }]), 83 | }, 84 | controllers: me::MeMeControllers { 85 | edges: Some(vec![Some(me::MeMeControllersEdges { 86 | node: Some(me::MeMeControllersEdgesNode { 87 | id: "id".to_string(), 88 | address: "0x123".to_string(), 89 | signers: Some(vec![me::MeMeControllersEdgesNodeSigners { 90 | id: "id".to_string(), 91 | type_: me::SignerType::webauthn, 92 | }]), 93 | }), 94 | })]), 95 | }, 96 | teams: me::MeMeTeams { 97 | edges: Some(vec![]), 98 | }, 99 | credits_plain: 0, 100 | }; 101 | 102 | let account = AccountInfo::from(me); 103 | 104 | assert_eq!(account.id, "id"); 105 | assert_eq!(account.username, "username".to_string()); 106 | assert_eq!(account.credentials.len(), 1); 107 | assert_eq!(account.credentials[0].id, "id".to_string()); 108 | assert_eq!(account.credentials[0].public_key, "foo".to_string()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /slot/src/graphql/paymaster/mod.rs: -------------------------------------------------------------------------------- 1 | pub use crate::graphql::deployments::Time; 2 | use graphql_client::GraphQLQuery; 3 | 4 | // Query for listing policies from a paymaster 5 | #[derive(GraphQLQuery)] 6 | #[graphql( 7 | schema_path = "schema.json", 8 | query_path = "src/graphql/paymaster/list_policies.graphql", 9 | response_derives = "Debug, Serialize, Clone", 10 | variables_derives = "Debug" 11 | )] 12 | pub struct ListPolicies; 13 | 14 | // Mutation for creating a paymaster 15 | #[derive(GraphQLQuery)] 16 | #[graphql( 17 | schema_path = "schema.json", 18 | query_path = "src/graphql/paymaster/create.graphql", 19 | variables_derives = "Debug, Clone" 20 | )] 21 | pub struct CreatePaymaster; 22 | 23 | // Mutation for adding policies 24 | #[derive(GraphQLQuery)] 25 | #[graphql( 26 | schema_path = "schema.json", 27 | query_path = "src/graphql/paymaster/add_policies.graphql", 28 | variables_derives = "Debug" 29 | )] 30 | pub struct AddPolicies; 31 | 32 | // Mutation for removing policies 33 | #[derive(GraphQLQuery)] 34 | #[graphql( 35 | schema_path = "schema.json", 36 | query_path = "src/graphql/paymaster/remove_policy.graphql", 37 | variables_derives = "Debug" 38 | )] 39 | pub struct RemovePolicy; 40 | 41 | // Mutation for removing all policies 42 | #[derive(GraphQLQuery)] 43 | #[graphql( 44 | schema_path = "schema.json", 45 | query_path = "src/graphql/paymaster/remove_all_policies.graphql", 46 | variables_derives = "Debug" 47 | )] 48 | pub struct RemoveAllPolicies; 49 | 50 | // Mutation for increasing budget 51 | #[derive(GraphQLQuery)] 52 | #[graphql( 53 | schema_path = "schema.json", 54 | query_path = "src/graphql/paymaster/increase_budget.graphql", 55 | variables_derives = "Debug, Clone" 56 | )] 57 | pub struct IncreaseBudget; 58 | 59 | // Mutation for decreasing budget 60 | #[derive(GraphQLQuery)] 61 | #[graphql( 62 | schema_path = "schema.json", 63 | query_path = "src/graphql/paymaster/decrease_budget.graphql", 64 | variables_derives = "Debug, Clone" 65 | )] 66 | pub struct DecreaseBudget; 67 | 68 | // Query for listing paymasters 69 | #[derive(GraphQLQuery)] 70 | #[graphql( 71 | schema_path = "schema.json", 72 | query_path = "src/graphql/paymaster/list_paymasters.graphql", 73 | response_derives = "Debug, Serialize, Clone", 74 | variables_derives = "Debug" 75 | )] 76 | pub struct ListPaymasters; 77 | 78 | // Query for paymaster stats 79 | #[derive(GraphQLQuery)] 80 | #[graphql( 81 | schema_path = "schema.json", 82 | query_path = "src/graphql/paymaster/stats.graphql", 83 | response_derives = "Debug, Serialize, Clone", 84 | variables_derives = "Debug" 85 | )] 86 | pub struct PaymasterStats; 87 | 88 | // Query for paymaster 89 | #[derive(GraphQLQuery)] 90 | #[graphql( 91 | schema_path = "schema.json", 92 | query_path = "src/graphql/paymaster/info.graphql", 93 | response_derives = "Debug, Serialize, Clone", 94 | variables_derives = "Debug" 95 | )] 96 | pub struct PaymasterInfo; 97 | 98 | // Update paymaster 99 | #[derive(GraphQLQuery)] 100 | #[graphql( 101 | schema_path = "schema.json", 102 | query_path = "src/graphql/paymaster/update.graphql", 103 | response_derives = "Debug, Serialize, Clone", 104 | variables_derives = "Debug" 105 | )] 106 | pub struct UpdatePaymaster; 107 | 108 | // Query for paymaster transactions 109 | #[derive(GraphQLQuery)] 110 | #[graphql( 111 | schema_path = "schema.json", 112 | query_path = "src/graphql/paymaster/transaction.graphql", 113 | response_derives = "Debug, Serialize, Clone", 114 | variables_derives = "Debug" 115 | )] 116 | pub struct PaymasterTransactions; 117 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Args, Subcommand}; 3 | use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | // Import the structs defined in the subcommand files 7 | use self::budget::BudgetCmd; 8 | use self::create::CreateArgs; 9 | use self::dune::DuneArgs; 10 | use self::info::InfoArgs; 11 | use self::policy::PolicyCmd; 12 | use self::stats::StatsArgs; 13 | use self::transactions::TransactionArgs; 14 | use self::update::UpdateArgs; 15 | mod budget; 16 | mod create; 17 | mod dune; 18 | mod info; 19 | mod policy; 20 | mod stats; 21 | mod transactions; 22 | mod update; 23 | pub(crate) mod utils; 24 | 25 | #[derive(Debug, Args, Serialize, Deserialize)] 26 | pub struct PolicyArgs { 27 | #[arg(long, help = "Contract address of the policy")] 28 | #[serde(rename = "contractAddress")] 29 | contract: String, 30 | 31 | #[arg(long, help = "Entrypoint name")] 32 | #[serde(rename = "entrypoint")] 33 | entrypoint: String, 34 | } 35 | 36 | /// Command group for managing Paymasters 37 | #[derive(Debug, Args)] 38 | #[command(next_help_heading = "Paymaster options")] 39 | pub struct PaymasterCmd { 40 | #[arg(help = "the name of the paymaster to manage.")] 41 | name: String, 42 | 43 | #[command(subcommand)] 44 | command: PaymasterSubcommand, 45 | } 46 | 47 | // Enum defining the specific paymaster actions 48 | #[derive(Subcommand, Debug)] 49 | enum PaymasterSubcommand { 50 | #[command(about = "Create a new paymaster.", alias = "c")] 51 | Create(CreateArgs), 52 | 53 | #[command(about = "Update paymaster.", alias = "u")] 54 | Update(UpdateArgs), 55 | 56 | #[command(about = "Manage paymaster policies.", alias = "p")] 57 | Policy(PolicyCmd), 58 | 59 | #[command(about = "Manage paymaster budget.", alias = "b")] 60 | Budget(BudgetCmd), 61 | 62 | #[command(about = "Manage paymaster stats.", alias = "s")] 63 | Stats(StatsArgs), 64 | 65 | #[command(about = "Get paymaster info.", alias = "i")] 66 | Info(InfoArgs), 67 | 68 | #[command(about = "Get paymaster transactions.", alias = "t")] 69 | Transactions(TransactionArgs), 70 | 71 | #[command(about = "Generate Dune SQL query for paymaster policies")] 72 | Dune(DuneArgs), 73 | } 74 | 75 | impl PaymasterCmd { 76 | // Main entry point for the paymaster command group 77 | pub async fn run(&self) -> Result<()> { 78 | match &self.command { 79 | PaymasterSubcommand::Create(args) => args.run(self.name.clone()).await, 80 | //PaymasterSubcommand::Get(args) => args.run(self.name.clone()).await, 81 | PaymasterSubcommand::Policy(cmd) => cmd.run(self.name.clone()).await, 82 | PaymasterSubcommand::Budget(cmd) => cmd.run(self.name.clone()).await, 83 | PaymasterSubcommand::Stats(cmd) => cmd.run(self.name.clone()).await, 84 | PaymasterSubcommand::Info(cmd) => cmd.run(self.name.clone()).await, 85 | PaymasterSubcommand::Update(cmd) => cmd.run(self.name.clone()).await, 86 | PaymasterSubcommand::Transactions(cmd) => cmd.run(self.name.clone()).await, 87 | PaymasterSubcommand::Dune(cmd) => cmd.run(self.name.clone()).await, 88 | } 89 | } 90 | } 91 | 92 | pub fn print_policies_table(policies: &[PolicyArgs]) { 93 | let mut table = Table::new(); 94 | table 95 | .load_preset(UTF8_FULL) 96 | .set_content_arrangement(ContentArrangement::Dynamic) 97 | .set_header(vec!["Contract Address", "Entry Point"]); 98 | 99 | for policy in policies { 100 | table.add_row(vec![ 101 | Cell::new(&policy.contract), 102 | Cell::new(&policy.entrypoint), 103 | ]); 104 | } 105 | 106 | println!("{}", table); 107 | } 108 | -------------------------------------------------------------------------------- /slot/src/api.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self}; 2 | 3 | use graphql_client::Response; 4 | use reqwest::RequestBuilder; 5 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 6 | use url::Url; 7 | 8 | use crate::error::Error; 9 | use crate::{credential::AccessToken, vars}; 10 | 11 | #[derive(Debug)] 12 | pub struct Client { 13 | base_url: Url, 14 | client: reqwest::Client, 15 | access_token: Option, 16 | } 17 | 18 | impl Client { 19 | pub fn new() -> Self { 20 | Self { 21 | access_token: None, 22 | client: reqwest::Client::new(), 23 | base_url: Url::parse(vars::get_cartridge_api_url().as_str()).expect("valid url"), 24 | } 25 | } 26 | 27 | pub fn new_with_token(token: AccessToken) -> Self { 28 | let mut client = Self::new(); 29 | client.set_token(token); 30 | client 31 | } 32 | 33 | pub fn set_token(&mut self, token: AccessToken) { 34 | self.access_token = Some(token); 35 | } 36 | 37 | pub async fn query(&self, body: &T) -> Result 38 | where 39 | R: DeserializeOwned, 40 | T: Serialize + ?Sized, 41 | { 42 | let path = "/query"; 43 | let token = self.access_token.as_ref().map(|t| t.token.as_str()); 44 | 45 | // TODO: return this as an error if token is None 46 | let bearer = format!("Bearer {}", token.unwrap_or_default()); 47 | 48 | let response = self 49 | .post(path) 50 | .header("Authorization", bearer) 51 | .json(body) 52 | .send() 53 | .await; 54 | 55 | if response.is_err() { 56 | return Err(Error::ReqwestError(response.err().unwrap())); 57 | } 58 | 59 | let res = response?; 60 | 61 | if res.status() == 403 { 62 | return Err(Error::InvalidOAuth); 63 | } 64 | 65 | if !res.status().is_success() { 66 | return Err(anyhow::anyhow!("API error: {}", res.status()).into()); 67 | } 68 | 69 | let res: Response = res.json().await?; 70 | 71 | if let Some(errors) = res.errors { 72 | Err(Error::Api(GraphQLErrors(errors))) 73 | } else { 74 | Ok(res.data.unwrap()) 75 | } 76 | } 77 | 78 | pub async fn oauth2(&self, code: &str) -> Result { 79 | #[derive(Deserialize)] 80 | struct OauthToken { 81 | #[serde(rename(deserialize = "access_token"))] 82 | token: String, 83 | #[serde(rename(deserialize = "token_type"))] 84 | r#type: String, 85 | } 86 | 87 | let path = "/oauth2/token"; 88 | let form = [("code", code)]; 89 | 90 | let response = self.post(path).form(&form).send().await?; 91 | let token: OauthToken = response.json().await?; 92 | 93 | Ok(AccessToken { 94 | token: token.token, 95 | r#type: token.r#type, 96 | }) 97 | } 98 | 99 | fn post(&self, path: &str) -> RequestBuilder { 100 | let url = self.get_url(path); 101 | self.client.post(url) 102 | } 103 | 104 | fn get_url(&self, path: &str) -> Url { 105 | let mut url = self.base_url.clone(); 106 | url.path_segments_mut().unwrap().extend(path.split('/')); 107 | url 108 | } 109 | } 110 | 111 | impl Default for Client { 112 | fn default() -> Self { 113 | Self::new() 114 | } 115 | } 116 | 117 | #[derive(Debug, thiserror::Error)] 118 | pub struct GraphQLErrors(Vec); 119 | 120 | impl fmt::Display for GraphQLErrors { 121 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 122 | for err in &self.0 { 123 | writeln!(f, "Error: {}", err.message)?; 124 | } 125 | Ok(()) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /cli/src/command/auth/login.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Result; 4 | use axum::{ 5 | extract::{Query, State}, 6 | response::{IntoResponse, Redirect, Response}, 7 | routing::get, 8 | Router, 9 | }; 10 | use clap::Args; 11 | use graphql_client::GraphQLQuery; 12 | use hyper::StatusCode; 13 | use log::error; 14 | use serde::Deserialize; 15 | use slot::{ 16 | account::AccountInfo, 17 | api::Client, 18 | browser, 19 | credential::Credentials, 20 | graphql::auth::{ 21 | me::{ResponseData, Variables}, 22 | Me, 23 | }, 24 | server::LocalServer, 25 | vars, 26 | }; 27 | use tokio::sync::mpsc::Sender; 28 | 29 | #[derive(Debug, Args)] 30 | pub struct LoginArgs; 31 | 32 | impl LoginArgs { 33 | pub async fn run(&self) -> Result<()> { 34 | let server = Self::callback_server().expect("Failed to create a server"); 35 | let port = server.local_addr()?.port(); 36 | let callback_uri = format!("http://localhost:{port}/callback"); 37 | 38 | let url = vars::get_cartridge_keychain_url(); 39 | 40 | let url = format!("{url}/slot?callback_uri={callback_uri}"); 41 | 42 | browser::open(&url)?; 43 | server.start().await?; 44 | 45 | Ok(()) 46 | } 47 | 48 | fn callback_server() -> Result { 49 | let (tx, rx) = tokio::sync::mpsc::channel::<()>(1); 50 | let shared_state = Arc::new(AppState::new(tx)); 51 | 52 | let router = Router::new() 53 | .route("/callback", get(handler)) 54 | .with_state(shared_state); 55 | 56 | Ok(LocalServer::new(router)?.with_shutdown_signal(rx)) 57 | } 58 | } 59 | 60 | #[derive(Debug, Deserialize)] 61 | struct CallbackPayload { 62 | code: Option, 63 | } 64 | 65 | #[derive(Clone)] 66 | struct AppState { 67 | shutdown_tx: Sender<()>, 68 | } 69 | 70 | impl AppState { 71 | fn new(shutdown_tx: Sender<()>) -> Self { 72 | Self { shutdown_tx } 73 | } 74 | 75 | async fn shutdown(&self) -> Result<()> { 76 | self.shutdown_tx.send(()).await?; 77 | Ok(()) 78 | } 79 | } 80 | 81 | #[derive(Debug, thiserror::Error)] 82 | enum CallbackError { 83 | #[error(transparent)] 84 | Other(#[from] anyhow::Error), 85 | 86 | #[error(transparent)] 87 | Slot(#[from] slot::Error), 88 | } 89 | 90 | impl IntoResponse for CallbackError { 91 | fn into_response(self) -> Response { 92 | let status = StatusCode::INTERNAL_SERVER_ERROR; 93 | let message = format!("Something went wrong: {self}"); 94 | (status, message).into_response() 95 | } 96 | } 97 | 98 | async fn handler( 99 | State(state): State>, 100 | Query(payload): Query, 101 | ) -> Result { 102 | // 1. Shutdown the server 103 | state.shutdown().await?; 104 | 105 | // 2. Get access token using the authorization code 106 | match payload.code { 107 | Some(code) => { 108 | let mut api = Client::new(); 109 | 110 | let token = api.oauth2(&code).await?; 111 | api.set_token(token.clone()); 112 | 113 | // fetch the account information 114 | let request_body = Me::build_query(Variables {}); 115 | let data: ResponseData = api.query(&request_body).await?; 116 | 117 | let account = data.me.expect("missing payload"); 118 | let account = AccountInfo::from(account); 119 | 120 | // 3. Store the access token locally 121 | Credentials::new(account, token).store()?; 122 | 123 | println!("You are now logged in!\n"); 124 | 125 | Ok(Redirect::permanent(&format!( 126 | "{}/success", 127 | vars::get_cartridge_keychain_url() 128 | ))) 129 | } 130 | None => { 131 | error!("User denied consent. Try again."); 132 | 133 | Ok(Redirect::permanent(&format!( 134 | "{}/failure", 135 | vars::get_cartridge_keychain_url() 136 | ))) 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /cli/src/command/deployments/update.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::enum_variant_names)] 2 | 3 | use super::services::UpdateServiceCommands; 4 | use crate::command::deployments::Tier; 5 | use anyhow::Result; 6 | use clap::Args; 7 | use slot::api::Client; 8 | use slot::credential::Credentials; 9 | use slot::graphql::deployments::update_deployment::{self, UpdateServiceInput}; 10 | use slot::graphql::deployments::{update_deployment::*, UpdateDeployment}; 11 | use slot::graphql::GraphQLQuery; 12 | 13 | #[derive(Debug, Args)] 14 | #[command(next_help_heading = "Update options")] 15 | pub struct UpdateArgs { 16 | #[arg(help = "The name of the project.")] 17 | pub project: String, 18 | 19 | #[arg(short, long)] 20 | #[arg(value_name = "tier")] 21 | #[arg(help = "Deployment tier.")] 22 | pub tier: Option, 23 | 24 | #[arg(long)] 25 | #[arg(help = "Enable observability for monitoring and metrics.")] 26 | pub observability: Option, 27 | 28 | #[command(subcommand)] 29 | update_commands: UpdateServiceCommands, 30 | } 31 | 32 | impl UpdateArgs { 33 | pub async fn run(&self) -> Result<()> { 34 | let service = match &self.update_commands { 35 | UpdateServiceCommands::Katana(args) => { 36 | let config = if let Some(config) = args.config.clone() { 37 | // Read the raw config file content 38 | let service_config = std::fs::read_to_string(&config)?; 39 | Some(slot::read::base64_encode_string(&service_config)) 40 | } else { 41 | None 42 | }; 43 | 44 | UpdateServiceInput { 45 | type_: DeploymentService::katana, 46 | version: None, 47 | config, 48 | torii: None, 49 | } 50 | } 51 | UpdateServiceCommands::Torii(args) => { 52 | let config = if let Some(config) = args.config.clone() { 53 | // Read the raw config file content 54 | let service_config = std::fs::read_to_string(&config)?; 55 | Some(slot::read::base64_encode_string(&service_config)) 56 | } else { 57 | None 58 | }; 59 | 60 | UpdateServiceInput { 61 | type_: DeploymentService::torii, 62 | version: args.version.clone(), 63 | config, 64 | torii: Some(ToriiUpdateInput { 65 | replicas: args.replicas, 66 | }), 67 | } 68 | } 69 | }; 70 | 71 | let tier = match &self.tier { 72 | None => None, 73 | Some(Tier::Basic) => Some(DeploymentTier::basic), 74 | Some(Tier::Pro) => Some(DeploymentTier::pro), 75 | Some(Tier::Epic) => Some(DeploymentTier::epic), 76 | Some(Tier::Legendary) => Some(DeploymentTier::legendary), 77 | Some(Tier::Insane) => Some(DeploymentTier::insane), // deprecated tier, kept for backwards compatibility 78 | }; 79 | 80 | let request_body = UpdateDeployment::build_query(Variables { 81 | project: self.project.clone(), 82 | tier, 83 | service, 84 | wait: Some(true), 85 | observability: self.observability, 86 | }); 87 | 88 | let user = Credentials::load()?; 89 | let client = Client::new_with_token(user.access_token); 90 | 91 | let response: update_deployment::ResponseData = client.query(&request_body).await?; 92 | 93 | let service = match &self.update_commands { 94 | UpdateServiceCommands::Katana(_) => "katana", 95 | UpdateServiceCommands::Torii(_) => "torii", 96 | }; 97 | 98 | println!("Update success 🚀"); 99 | 100 | // Display observability secret if present 101 | if let Some(observability_secret) = &response.update_deployment.observability_secret { 102 | super::print_observability_secret(observability_secret, &self.project, service); 103 | } 104 | 105 | println!( 106 | "\nStream logs with `slot deployments logs {} {service} -f`", 107 | self.project 108 | ); 109 | 110 | Ok(()) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /cli/src/command/teams/invoices.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use chrono::prelude::*; 3 | use clap::Args; 4 | use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; 5 | use slot::api::Client; 6 | use slot::credential::Credentials; 7 | use slot::graphql::team::team_invoices::{InvoiceOrder, Variables}; 8 | use slot::graphql::team::team_invoices::{InvoiceOrderField, OrderDirection}; 9 | use slot::graphql::team::TeamInvoices; 10 | use slot::graphql::GraphQLQuery; 11 | 12 | #[derive(Debug, Args)] 13 | pub struct InvoicesArgs {} 14 | 15 | impl InvoicesArgs { 16 | pub async fn run(&self, team_name: String) -> Result<()> { 17 | let request_body = TeamInvoices::build_query(Variables { 18 | team: team_name.clone(), 19 | first: Some(100), // Get up to 100 invoices 20 | after: None, 21 | order_by: Some(InvoiceOrder { 22 | field: InvoiceOrderField::CREATED_AT, 23 | direction: OrderDirection::DESC, // Most recent first 24 | }), 25 | }); 26 | 27 | let user = Credentials::load()?; 28 | let client = Client::new_with_token(user.access_token); 29 | 30 | let data: slot::graphql::team::team_invoices::ResponseData = 31 | client.query(&request_body).await?; 32 | let team = data 33 | .team 34 | .ok_or_else(|| anyhow::anyhow!("Team '{}' not found", team_name))?; 35 | 36 | let edges = team.invoices.edges.unwrap_or_default(); 37 | 38 | if edges.is_empty() { 39 | println!("No invoices found for team '{}'", team_name); 40 | return Ok(()); 41 | } 42 | 43 | let current_month = Utc::now().format("%Y-%m").to_string(); 44 | 45 | for edge in edges.into_iter().flatten() { 46 | if let Some(node) = edge.node { 47 | let is_current_month = node.month == current_month; 48 | 49 | let mut table = Table::new(); 50 | table 51 | .load_preset(UTF8_FULL) 52 | .set_content_arrangement(ContentArrangement::Dynamic); 53 | 54 | let title = if is_current_month { 55 | format!("Invoices for team `{}` (Current Month)", team_name) 56 | } else { 57 | format!("Invoices for team `{}`", team_name) 58 | }; 59 | 60 | table.set_header(vec![title, "".to_string()]); 61 | 62 | table.add_row(vec![Cell::new("Month"), Cell::new(&node.month)]); 63 | table.add_row(vec![ 64 | Cell::new("Total Credits (top up + reimbursement)"), 65 | Cell::new(format_credits(node.total_credits)), 66 | ]); 67 | table.add_row(vec![ 68 | Cell::new(" -> Incubator Credits"), 69 | Cell::new(format_credits(node.incubator_credits)), 70 | ]); 71 | table.add_row(vec![ 72 | Cell::new("Debits (slot + paymaster)"), 73 | Cell::new(format_credits(node.total_debits)), 74 | ]); 75 | table.add_row(vec![ 76 | Cell::new(" -> Slot Debits"), 77 | Cell::new(format_credits(node.slot_debits)), 78 | ]); 79 | table.add_row(vec![ 80 | Cell::new(" -> Paymaster Debits"), 81 | Cell::new(format_credits(node.paymaster_debits)), 82 | ]); 83 | table.add_row(vec![ 84 | Cell::new("Net Amount Paid"), 85 | Cell::new(format_credits(node.net_amount)), 86 | ]); 87 | table.add_row(vec![ 88 | Cell::new("Incubator Stage"), 89 | Cell::new(node.incubator_stage.unwrap_or_else(|| "None".to_string())), 90 | ]); 91 | table.add_row(vec![ 92 | Cell::new("Finalized"), 93 | Cell::new(if node.finalized { "Yes" } else { "No" }), 94 | ]); 95 | 96 | println!("{table}"); 97 | println!(); 98 | } 99 | } 100 | 101 | Ok(()) 102 | } 103 | } 104 | 105 | fn format_credits(credits: i64) -> String { 106 | let dollars = credits as f64 / 100.0 / 1e6; 107 | format!("${:.2}", dollars) 108 | } 109 | -------------------------------------------------------------------------------- /cli/src/command/paymasters/list.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; 4 | use slot::api::Client; 5 | use slot::credential::Credentials; 6 | use slot::graphql::paymaster::list_paymasters::PaymasterBudgetFeeUnit; 7 | use slot::graphql::paymaster::list_paymasters::{ResponseData, Variables}; 8 | use slot::graphql::paymaster::ListPaymasters; 9 | use slot::graphql::GraphQLQuery; 10 | 11 | const BUDGET_DECIMALS: i64 = 1_000_000; 12 | 13 | #[derive(Debug, Args)] 14 | #[command(next_help_heading = "List paymasters options")] 15 | pub struct ListArgs {} 16 | 17 | impl ListArgs { 18 | pub async fn run(&self) -> Result<()> { 19 | let request_body = ListPaymasters::build_query(Variables {}); 20 | 21 | let user = Credentials::load()?; 22 | let client = Client::new_with_token(user.access_token); 23 | 24 | // 5. Process and Print Results - adapt to the new nested structure 25 | let mut table = Table::new(); 26 | table 27 | .load_preset(UTF8_FULL) 28 | .set_content_arrangement(ContentArrangement::Dynamic) 29 | .set_header(vec!["Paymaster", "Team", "Budget", "Active"]); 30 | 31 | let data: ResponseData = client.query(&request_body).await?; 32 | let mut paymasters_found = false; // Ensure this is declared before the selection or adjust scope 33 | if let Some(me) = data.me { 34 | if let Some(teams_edges) = me.teams.edges { 35 | // Iterate through teams, filtering out None edges and nodes 36 | let paymasters_data = teams_edges 37 | .iter() 38 | .filter_map(|team_edge_opt| team_edge_opt.as_ref()) // Get &TeamEdge, filtering None 39 | .filter_map(|team_edge| team_edge.node.as_ref()) // Get &TeamNode, filtering None 40 | .filter_map(|team_node| { 41 | // Get paymaster edges for the team, keeping team_node info 42 | // Handle Option and Option>> 43 | team_node 44 | .paymasters // Access the connection struct directly 45 | .edges // Access the 'edges' field (likely Option>>) 46 | .as_ref() // Call as_ref() on the Option> 47 | .map(|pm_edges| (team_node, pm_edges)) // pm_edges is now &Vec> 48 | }) 49 | .flat_map(|(team_node, pm_edges)| { 50 | // Flatten the list of paymasters across all teams 51 | pm_edges 52 | .iter() 53 | .filter_map(|pm_edge_opt| pm_edge_opt.as_ref()) // Get &PaymasterEdge, filtering None 54 | .filter_map(move |pm_edge| { 55 | // Get &PaymasterNode from Option, keeping team_node info 56 | pm_edge.node.as_ref().map(|pm_node| (team_node, pm_node)) 57 | }) 58 | }); 59 | 60 | // Populate the table 61 | for (team_node, pm_node) in paymasters_data { 62 | paymasters_found = true; 63 | let budget = match pm_node.budget_fee_unit { 64 | PaymasterBudgetFeeUnit::CREDIT => { 65 | let usd_amount = (pm_node.budget / BUDGET_DECIMALS) as f64 * 0.01; 66 | format!("${:.2} USD", usd_amount) 67 | } 68 | PaymasterBudgetFeeUnit::STRK => { 69 | format!("{} STRK", pm_node.budget / BUDGET_DECIMALS) 70 | } 71 | _ => "UNKNOWN".to_string(), 72 | }; 73 | table.add_row(vec![ 74 | Cell::new(pm_node.name.as_str()), 75 | Cell::new(&team_node.name), 76 | Cell::new(&budget), 77 | Cell::new(pm_node.active.to_string()), 78 | ]); 79 | } 80 | } 81 | } 82 | 83 | if !paymasters_found { 84 | println!("No paymasters found for your teams."); 85 | } else { 86 | println!("{table}"); 87 | } 88 | 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cli/src/command/deployments/logs.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | sync::{ 4 | atomic::{AtomicBool, Ordering}, 5 | Arc, 6 | }, 7 | time::Duration, 8 | }; 9 | 10 | // use tokio::selectV 11 | use anyhow::Result; 12 | use clap::Args; 13 | use slot::credential::Credentials; 14 | use slot::graphql::deployments::deployment_logs::DeploymentService; 15 | use slot::graphql::{deployments::deployment_logs::*, GraphQLQuery}; 16 | use slot::{api::Client, graphql::deployments::DeploymentLogs}; 17 | 18 | use super::services::Service; 19 | 20 | #[derive(Debug, Args)] 21 | #[command(next_help_heading = "Deployment logs options")] 22 | pub struct LogsArgs { 23 | #[arg(help = "The project of the deployment.")] 24 | pub project: String, 25 | 26 | #[arg(help = "The name of the deployment service.")] 27 | pub service: Service, 28 | 29 | #[arg(short, long = "since")] 30 | #[arg(help = "Display logs after this RFC3339 timestamp.")] 31 | pub since: Option, 32 | 33 | #[arg(short, long = "limit", default_value = "25")] 34 | #[arg(help = "Display only the most recent `n` lines of logs.")] 35 | pub limit: i64, 36 | 37 | #[arg(short, long = "follow", default_value = "false")] 38 | #[arg(help = "Stream service logs.")] 39 | pub follow: bool, 40 | 41 | #[arg(short, long = "container")] 42 | #[arg(help = "Filter logs by container name.")] 43 | pub container: Option, 44 | } 45 | 46 | impl LogsArgs { 47 | pub async fn run(&self) -> Result<()> { 48 | let reader = LogReader::new(self.service.clone(), self.project.clone()); 49 | 50 | if self.follow { 51 | reader 52 | .stream(self.since.clone(), self.container.clone()) 53 | .await?; 54 | } else { 55 | let logs = reader 56 | .query(self.since.clone(), self.limit, self.container.clone()) 57 | .await?; 58 | println!("{}", logs.content); 59 | } 60 | 61 | Ok(()) 62 | } 63 | } 64 | 65 | pub struct LogReader { 66 | client: Client, 67 | service: Service, 68 | project: String, 69 | } 70 | 71 | impl LogReader { 72 | pub fn new(service: Service, project: String) -> Self { 73 | let user = Credentials::load().unwrap(); 74 | let client = Client::new_with_token(user.access_token); 75 | LogReader { 76 | client, 77 | service, 78 | project, 79 | } 80 | } 81 | 82 | pub async fn query( 83 | &self, 84 | since: Option, 85 | limit: i64, 86 | container: Option, 87 | ) -> Result { 88 | let service = match self.service { 89 | Service::Katana => DeploymentService::katana, 90 | Service::Torii => DeploymentService::torii, 91 | }; 92 | 93 | let request_body = DeploymentLogs::build_query(Variables { 94 | project: self.project.clone(), 95 | service, 96 | since, 97 | limit: Some(limit), 98 | container, 99 | }); 100 | 101 | let data: ResponseData = self.client.query(&request_body).await?; 102 | 103 | let logs = data.deployment.map(|deployment| deployment.logs).unwrap(); 104 | 105 | Ok(logs) 106 | } 107 | 108 | pub async fn stream(&self, since: Option, container: Option) -> Result<()> { 109 | let running = Arc::new(AtomicBool::new(true)); 110 | let r = running.clone(); 111 | ctrlc::set_handler(move || { 112 | r.store(false, Ordering::SeqCst); 113 | }) 114 | .expect("Error setting Ctrl-C handler"); 115 | 116 | let mut logs = self.query(since, 1, container.clone()).await?; 117 | let mut printed_logs = HashSet::new(); 118 | 119 | let mut since = logs.until; 120 | while running.load(Ordering::SeqCst) { 121 | tokio::time::sleep(Duration::from_millis(1000)).await; 122 | logs = self 123 | .query(Some(since.clone()), 25, container.clone()) 124 | .await?; 125 | 126 | if !printed_logs.contains(&logs.content) { 127 | println!("{}", logs.content); 128 | printed_logs.insert(logs.content.clone()); // Add the log to the buffer 129 | } 130 | 131 | since = logs.until 132 | } 133 | 134 | Ok(()) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /cli/src/command/auth/info.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use colored::*; 4 | use slot::graphql::auth::{me::*, Me}; 5 | use slot::graphql::GraphQLQuery; 6 | use slot::{api::Client, credential::Credentials}; 7 | 8 | #[derive(Debug, Args)] 9 | pub struct InfoArgs; 10 | 11 | fn format_usd(credits: i64) -> String { 12 | // format two digits currency 13 | let amount = credits as f64 / 100f64; 14 | // format two digits e.g. $1.02 15 | format!("${:.2}", amount) 16 | } 17 | 18 | impl InfoArgs { 19 | // TODO: find the account info from `credentials.json` first before making a request 20 | pub async fn run(&self) -> Result<()> { 21 | let credentials = Credentials::load()?; 22 | let client = Client::new_with_token(credentials.access_token); 23 | 24 | let request_body = Me::build_query(Variables {}); 25 | let res: ResponseData = client.query(&request_body).await?; 26 | let info = res.me.clone().unwrap(); 27 | println!("Username: {}", info.username); 28 | println!( 29 | "Balance: {}", 30 | // round usd to 2 digits 31 | format_usd(info.credits_plain) 32 | ); 33 | 34 | println!(); 35 | println!("Teams:"); 36 | let teams = res.me.unwrap().teams.edges.unwrap(); 37 | 38 | if teams.is_empty() { 39 | println!(" No teams yet"); 40 | } 41 | 42 | for edge in teams { 43 | let team = edge.unwrap().node.unwrap(); 44 | if team.deleted { 45 | continue; 46 | } 47 | println!(); 48 | println!(" Name: {}", team.name); 49 | println!( 50 | " Balance: {}", 51 | // round usd to 2 digits 52 | format_usd((team.credits as f64 / 1e6) as i64) 53 | ); 54 | 55 | let total_balance = if let Some(incubator_stage) = &team.incubator_stage { 56 | println!(" Incubator Stage: {:?}", incubator_stage); 57 | 58 | // Determine total balance based on incubator stage 59 | // Match against the Debug representation of the enum 60 | match format!("{:?}", incubator_stage).as_str() { 61 | "senpai" => 500000, // $5k in cents 62 | "sensei" => 2500000, // $25k in cents 63 | _ => 0, 64 | } 65 | } else { 66 | 0 67 | }; 68 | 69 | println!(" Total Balance: {}", format_usd(total_balance)); 70 | 71 | println!( 72 | " Total Spent: {}", 73 | format_usd((team.total_debits as f64 / 1e6) as i64) 74 | ); 75 | 76 | // Calculate remaining balance 77 | let remaining_balance = total_balance - ((team.total_debits as f64 / 1e6) as i64); 78 | println!( 79 | " Remaining Incubator Credits: {}", 80 | format_usd(remaining_balance) 81 | ); 82 | 83 | println!(" Deployments:"); 84 | let deployments = team.deployments.edges.unwrap(); 85 | let active_deployments: Vec<_> = deployments 86 | .iter() 87 | .filter_map(|edge| edge.as_ref()) 88 | .filter_map(|edge| edge.node.as_ref()) 89 | .filter(|deployment| format!("{:?}", deployment.status) != "deleted") 90 | .collect(); 91 | 92 | if active_deployments.is_empty() { 93 | println!(" No deployments yet"); 94 | } 95 | 96 | for deployment in active_deployments { 97 | let deprecated_indicator = if deployment.deprecated.unwrap_or(false) { 98 | format!(" {}", "(deprecated!)".bold()) 99 | } else { 100 | String::new() 101 | }; 102 | 103 | println!( 104 | " Deployment: {}/{}{}", 105 | deployment.project, deployment.service_id, deprecated_indicator 106 | ); 107 | } 108 | 109 | println!(" Members:"); 110 | let members = team.membership.edges.unwrap(); 111 | for edge in members { 112 | let member = edge.unwrap().node.unwrap(); 113 | println!( 114 | " Member: {} ({:?})", 115 | member.account.username, member.role 116 | ); 117 | } 118 | } 119 | 120 | Ok(()) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /cli/src/command/deployments/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Subcommand; 3 | use colored::*; 4 | use strum_macros::Display; 5 | 6 | use self::{ 7 | accounts::AccountsArgs, create::CreateArgs, delete::DeleteArgs, describe::DescribeArgs, 8 | list::ListArgs, logs::LogsArgs, update::UpdateArgs, 9 | }; 10 | use crate::command::deployments::transfer::TransferArgs; 11 | 12 | mod accounts; 13 | mod create; 14 | mod delete; 15 | mod describe; 16 | mod list; 17 | mod logs; 18 | mod services; 19 | mod transfer; 20 | mod update; 21 | 22 | pub const CARTRIDGE_BASE_URL: &str = "https://api.cartridge.gg/x"; 23 | 24 | #[derive(Subcommand, Debug)] 25 | pub enum Deployments { 26 | #[command(about = "Create a new deployment.")] 27 | Create(CreateArgs), 28 | 29 | #[command(about = "Delete a deployment.")] 30 | Delete(DeleteArgs), 31 | 32 | #[command(about = "Update a deployment.")] 33 | Update(UpdateArgs), 34 | 35 | #[command(about = "Describe a deployment's configuration.")] 36 | Describe(DescribeArgs), 37 | 38 | #[command(about = "List all deployments.", aliases = ["ls"])] 39 | List(ListArgs), 40 | 41 | #[command(about = "Transfer a deployment.")] 42 | Transfer(TransferArgs), 43 | 44 | #[command(about = "Fetch logs for a deployment.")] 45 | Logs(LogsArgs), 46 | 47 | #[command(about = "Fetch Katana accounts.")] 48 | Accounts(AccountsArgs), 49 | } 50 | 51 | impl Deployments { 52 | pub async fn run(&self) -> Result<()> { 53 | match &self { 54 | Deployments::Create(args) => args.run().await, 55 | Deployments::Delete(args) => args.run().await, 56 | Deployments::Update(args) => args.run().await, 57 | Deployments::Describe(args) => args.run().await, 58 | Deployments::List(args) => args.run().await, 59 | Deployments::Transfer(args) => args.run().await, 60 | Deployments::Logs(args) => args.run().await, 61 | Deployments::Accounts(args) => args.run().await, 62 | } 63 | } 64 | } 65 | 66 | #[derive(clap::ValueEnum, Clone, Debug, serde::Serialize, PartialEq, Eq, Hash, Display)] 67 | pub enum Tier { 68 | Basic, 69 | Pro, 70 | Epic, 71 | Legendary, 72 | #[clap(skip)] 73 | Insane, 74 | } 75 | 76 | /// Returns the service url for a given project and service. 77 | pub(crate) fn service_url(project: &str, service: &str) -> String { 78 | format!("{}/{}/{}", CARTRIDGE_BASE_URL, project, service) 79 | } 80 | 81 | /// Prints the configuration file for a given project and service. 82 | pub(crate) fn print_config_file(config: &str) { 83 | println!("\n─────────────── Configuration ───────────────"); 84 | pretty_print_toml(config); 85 | println!("──────────────────────────────────────────────"); 86 | } 87 | 88 | /// Pretty prints a TOML string. 89 | pub(crate) fn pretty_print_toml(str: &str) { 90 | let mut first_line = true; 91 | for line in str.lines() { 92 | if line.starts_with("[") { 93 | // Print section headers. 94 | if !first_line { 95 | println!(); 96 | first_line = false; 97 | } 98 | println!("{}", line.bright_blue()); 99 | } else if line.contains('=') { 100 | // Print key-value pairs with keys in green and values. 101 | let parts: Vec<&str> = line.splitn(2, '=').collect(); 102 | if parts.len() == 2 { 103 | let key = parts[0].trim(); 104 | let value = parts[1].trim().replace("\"", ""); 105 | 106 | println!("{} = {}", key.bright_black(), value); 107 | } else { 108 | println!("{}", line); 109 | } 110 | } else { 111 | // Remove line that are empty to have more compact output. 112 | if line.trim().is_empty() { 113 | continue; 114 | } 115 | 116 | // Print other lines normally. 117 | println!("{}", line); 118 | } 119 | } 120 | } 121 | 122 | /// Prints observability secret information with Prometheus and Grafana URLs. 123 | pub(crate) fn print_observability_secret(secret: &str, project: &str, service: &str) { 124 | let base_url = service_url(project, service); 125 | println!("\nObservability Secret: {}", secret); 126 | println!("Save this secret - it will be needed to access Prometheus and Grafana."); 127 | println!("The username is 'admin' and the password is the secret."); 128 | println!("\nPrometheus URL: {}/prometheus", base_url); 129 | println!("Grafana URL: {}/grafana", base_url); 130 | } 131 | -------------------------------------------------------------------------------- /cli/src/command/deployments/accounts.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::enum_variant_names)] 2 | 3 | use anyhow::Result; 4 | use clap::Args; 5 | use katana_primitives::contract::ContractAddress; 6 | use katana_primitives::genesis::allocation::{DevAllocationsGenerator, GenesisAccountAlloc}; 7 | use katana_primitives::genesis::Genesis; 8 | use slot::graphql::deployments::{katana_accounts::*, KatanaAccounts}; 9 | use slot::graphql::GraphQLQuery; 10 | 11 | use slot::api::Client; 12 | use slot::credential::Credentials; 13 | 14 | use super::services::KatanaAccountCommands; 15 | 16 | #[derive(Debug, Args)] 17 | #[command(next_help_heading = "Accounts options")] 18 | pub struct AccountsArgs { 19 | #[arg(help = "The name of the project.")] 20 | pub project: String, 21 | 22 | #[command(subcommand)] 23 | accounts_commands: KatanaAccountCommands, 24 | } 25 | 26 | impl AccountsArgs { 27 | pub async fn run(&self) -> Result<()> { 28 | let request_body = KatanaAccounts::build_query(Variables { 29 | project: self.project.clone(), 30 | }); 31 | 32 | let user = Credentials::load()?; 33 | let client = Client::new_with_token(user.access_token); 34 | 35 | let _data: ResponseData = client.query(&request_body).await?; 36 | 37 | // TODO use data.deployment.config and parse `accounts` 38 | // let mut accounts_vec = Vec::new(); 39 | // for account in accounts { 40 | // let address = 41 | // ContractAddress::new(Felt::from_str(&account.address).unwrap()); 42 | // 43 | // let public_key = Felt::from_str(&account.public_key).unwrap(); 44 | // let private_key = Felt::from_str(&account.private_key).unwrap(); 45 | // 46 | // let genesis_account = GenesisAccount { 47 | // public_key, 48 | // ..GenesisAccount::default() 49 | // }; 50 | // 51 | // accounts_vec.push(( 52 | // address, 53 | // GenesisAccountAlloc::DevAccount(DevGenesisAccount { 54 | // private_key, 55 | // inner: genesis_account, 56 | // }), 57 | // )); 58 | // } 59 | // print_genesis_accounts(accounts_vec.iter().map(|(a, b)| (a, b)), None); 60 | 61 | // NOTICE: This is implementation assume that the Katana instance is configured with the default seed and total number of accounts. If not, the 62 | // generated addresses will be different from the ones in the Katana instance. This is rather a hack until `slot` can return the addresses directly (or 63 | // at least the exact configurations of the instance). 64 | 65 | let seed = "0"; 66 | let total_accounts = 10; 67 | 68 | let accounts = DevAllocationsGenerator::new(total_accounts) 69 | .with_seed(parse_seed(seed)) 70 | .generate(); 71 | 72 | let mut genesis = Genesis::default(); 73 | genesis.extend_allocations(accounts.into_iter().map(|(k, v)| (k, v.into()))); 74 | print_genesis_accounts(genesis.accounts().peekable(), Some(seed)); 75 | 76 | Ok(()) 77 | } 78 | } 79 | 80 | fn print_genesis_accounts<'a, Accounts>(accounts: Accounts, seed: Option<&str>) 81 | where 82 | Accounts: Iterator, 83 | { 84 | println!( 85 | r" 86 | 87 | PREFUNDED ACCOUNTS 88 | ==================" 89 | ); 90 | 91 | for (addr, account) in accounts { 92 | if let Some(pk) = account.private_key() { 93 | println!( 94 | r" 95 | | Account address | {addr} 96 | | Private key | {pk:#x} 97 | | Public key | {:#x}", 98 | account.public_key() 99 | ) 100 | } else { 101 | println!( 102 | r" 103 | | Account address | {addr} 104 | | Public key | {:#x}", 105 | account.public_key() 106 | ) 107 | } 108 | } 109 | 110 | if let Some(seed) = seed { 111 | println!( 112 | r" 113 | ACCOUNTS SEED 114 | ============= 115 | {seed} 116 | " 117 | ); 118 | } 119 | } 120 | 121 | // Mimic how Katana parse the seed to generate the predeployed accounts 122 | // https://github.com/dojoengine/dojo/blob/85c0b025f108bd1ed64a5b35cfb574f61545a0ff/crates/katana/cli/src/utils.rs#L24-L34 123 | fn parse_seed(seed: &str) -> [u8; 32] { 124 | let seed = seed.as_bytes(); 125 | 126 | if seed.len() >= 32 { 127 | unsafe { *(seed[..32].as_ptr() as *const [u8; 32]) } 128 | } else { 129 | let mut actual_seed = [0u8; 32]; 130 | seed.iter() 131 | .enumerate() 132 | .for_each(|(i, b)| actual_seed[i] = *b); 133 | actual_seed 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | types: [closed] 7 | branches: 8 | - main 9 | 10 | env: 11 | CARGO_TERM_COLOR: always 12 | RUST_VERSION: 1.85.0 13 | REGISTRY_IMAGE: ghcr.io/${{ github.repository }} 14 | CMAKE_POLICY_VERSION_MINIMUM: "3.5" 15 | 16 | jobs: 17 | prepare: 18 | if: (github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'prepare-release') || github.event_name == 'workflow_dispatch' 19 | runs-on: ubuntu-latest 20 | outputs: 21 | tag_name: ${{ steps.release_info.outputs.tag_name }} 22 | steps: 23 | - uses: actions/checkout@v6 24 | - name: Get version 25 | id: release_info 26 | run: | 27 | cargo install cargo-get 28 | echo "tag_name=$(cargo get workspace.package.version)" >> $GITHUB_OUTPUT 29 | 30 | release: 31 | name: ${{ matrix.job.target }} (${{ matrix.job.os }}) 32 | needs: prepare 33 | runs-on: ${{ matrix.job.os }} 34 | env: 35 | PLATFORM_NAME: ${{ matrix.job.platform }} 36 | TARGET: ${{ matrix.job.target }} 37 | ARCH: ${{ matrix.job.arch }} 38 | strategy: 39 | matrix: 40 | job: 41 | - os: ubuntu-latest 42 | platform: linux 43 | target: x86_64-unknown-linux-gnu 44 | arch: amd64 45 | - os: ubuntu-latest 46 | platform: linux 47 | target: aarch64-unknown-linux-gnu 48 | arch: arm64 49 | svm_target_platform: linux-aarch64 50 | - os: macos-latest-xlarge 51 | platform: darwin 52 | target: x86_64-apple-darwin 53 | arch: amd64 54 | - os: macos-latest 55 | platform: darwin 56 | target: aarch64-apple-darwin 57 | arch: arm64 58 | - os: windows-latest 59 | platform: win32 60 | target: x86_64-pc-windows-msvc 61 | arch: amd64 62 | 63 | steps: 64 | - uses: actions/checkout@v6 65 | 66 | - uses: dtolnay/rust-toolchain@master 67 | name: Rust Toolchain Setup 68 | with: 69 | targets: ${{ matrix.job.target }} 70 | toolchain: ${{ env.RUST_VERSION }} 71 | 72 | - uses: Swatinem/rust-cache@v2 73 | with: 74 | cache-on-failure: true 75 | 76 | # Required to build Katana at the moment. 77 | - uses: oven-sh/setup-bun@v1 78 | with: 79 | bun-version: latest 80 | 81 | - name: Apple M1 setup 82 | if: ${{ matrix.job.target == 'aarch64-apple-darwin' }} 83 | run: | 84 | echo "SDKROOT=$(xcrun -sdk macosx --show-sdk-path)" >> $GITHUB_ENV 85 | echo "MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version)" >> $GITHUB_ENV 86 | 87 | - name: Linux ARM setup 88 | if: ${{ matrix.job.target == 'aarch64-unknown-linux-gnu' }} 89 | run: | 90 | sudo apt-get update -y 91 | sudo apt-get install -y gcc-aarch64-linux-gnu libssl-dev 92 | echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV 93 | 94 | - name: Build binaries 95 | run: cargo build --release --bins --target ${{ matrix.job.target }} 96 | 97 | - name: Archive binaries 98 | id: artifacts 99 | env: 100 | VERSION_NAME: v${{ needs.prepare.outputs.tag_name }} 101 | run: | 102 | if [ "$PLATFORM_NAME" == "linux" ]; then 103 | tar -czvf "slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" -C ./target/${TARGET}/release slot 104 | echo "file_name=slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" >> $GITHUB_OUTPUT 105 | elif [ "$PLATFORM_NAME" == "darwin" ]; then 106 | gtar -czvf "slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" -C ./target/${TARGET}/release slot 107 | echo "file_name=slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" >> $GITHUB_OUTPUT 108 | else 109 | cd ./target/${TARGET}/release 110 | 7z a -tzip "slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" slot.exe 111 | mv "slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" ../../../ 112 | echo "file_name=slot_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" >> $GITHUB_OUTPUT 113 | fi 114 | shell: bash 115 | 116 | - name: Upload release artifacts 117 | uses: actions/upload-artifact@v5 118 | with: 119 | name: artifacts-${{ matrix.job.target }} 120 | path: ${{ steps.artifacts.outputs.file_name }} 121 | retention-days: 1 122 | 123 | create-release: 124 | runs-on: ubuntu-latest 125 | needs: [prepare, release] 126 | env: 127 | GITHUB_USER: ${{ github.repository_owner }} 128 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 129 | 130 | steps: 131 | - uses: actions/checkout@v6 132 | - uses: actions/download-artifact@v6 133 | with: 134 | pattern: artifacts-* 135 | path: artifacts 136 | merge-multiple: true 137 | - id: version_info 138 | run: | 139 | cargo install cargo-get 140 | echo "version=$(cargo get workspace.package.version)" >> $GITHUB_OUTPUT 141 | - name: Display structure of downloaded files 142 | run: ls -R artifacts 143 | - run: gh release create v${{ steps.version_info.outputs.version }} ./artifacts/*.gz --generate-notes 144 | -------------------------------------------------------------------------------- /scripts/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eux 4 | 5 | # Creates a simple Torii config file. 6 | create_torii_config() { 7 | local config_path=$1 8 | cat > "$config_path" << EOF 9 | world_address = "0x585a28495ca41bece7640b0ccf2eff199ebe70cc381fa73cb34cc5721614fbd" 10 | rpc = "https://api.cartridge.gg/x/starknet/sepolia" 11 | EOF 12 | } 13 | 14 | # Creates a simple Katana config file. 15 | create_katana_config() { 16 | local config_path=$1 17 | cat > "$config_path" << EOF 18 | block_time = 5000 19 | EOF 20 | } 21 | 22 | # Checks if Katana is ready and responding. 23 | check_katana() { 24 | local project=$1 25 | local max_retries=3 26 | local retry_count=0 27 | local res 28 | 29 | while [ $retry_count -lt $max_retries ]; do 30 | res=$(curl --request POST -s \ 31 | --url "https://api.cartridge.gg/x/$project/katana" \ 32 | --header 'accept: application/json' \ 33 | --header 'content-type: application/json' \ 34 | --data ' 35 | { 36 | "id": 1, 37 | "jsonrpc": "2.0", 38 | "method": "starknet_specVersion" 39 | } 40 | ') 41 | 42 | # Check for both valid JSON-RPC response and absence of timeout 43 | if echo "$res" | grep -q '"jsonrpc":"2.0"' && ! echo "$res" | grep -q '"error"'; then 44 | return 0 45 | fi 46 | 47 | retry_count=$((retry_count + 1)) 48 | if [ $retry_count -lt $max_retries ]; then 49 | echo "Katana check attempt $retry_count failed, retrying in 5 seconds..." 50 | sleep 5 51 | fi 52 | done 53 | 54 | echo "Katana check failed after $max_retries attempts, last response: $res" 55 | return 1 56 | } 57 | 58 | # Checks if Torii is ready and responding. 59 | check_torii() { 60 | local project=$1 61 | local max_retries=3 62 | local retry_count=0 63 | local res 64 | 65 | while [ $retry_count -lt $max_retries ]; do 66 | res=$(curl --request POST -s \ 67 | --url "https://api.cartridge.gg/x/$project/torii/graphql" \ 68 | --data ' 69 | { 70 | "query": "query { models { edges { node { id } } } }" 71 | } 72 | ') 73 | 74 | if echo "$res" | grep -q '"models":'; then 75 | return 0 76 | fi 77 | 78 | retry_count=$((retry_count + 1)) 79 | if [ $retry_count -lt $max_retries ]; then 80 | echo "Torii check attempt $retry_count failed, retrying in 5 seconds..." 81 | sleep 5 82 | fi 83 | done 84 | 85 | echo "Torii check failed after $max_retries attempts, last response: $res" 86 | return 1 87 | } 88 | 89 | # Tests Katana deployment creation, tier update, config update and deletion. 90 | test_katana() { 91 | local project=$1 92 | local config_path=$2 93 | 94 | create_katana_config "$config_path" 95 | cargo run -- d create "$project" katana --config "$config_path" 96 | 97 | sleep 10 98 | 99 | if ! check_katana "$project"; then 100 | cargo run -- d delete "$project" katana -f || true 101 | return 1 102 | fi 103 | 104 | cargo run -- d update --tier epic "$project" katana 105 | # This delay is a bit higher, but it seems like Katana takes a bit longer to 106 | # start up in higher tiers. 107 | # Also, the update tier command returns directly, when it may need to wait for 108 | # the deployment to be ready. 109 | sleep 60 110 | 111 | if ! check_katana "$project"; then 112 | cargo run -- d delete "$project" katana -f || true 113 | return 1 114 | fi 115 | 116 | create_katana_config "$config_path" 117 | cargo run -- d update "$project" katana --config "$config_path" 118 | # Faster update, since it's only a restart with the new config 119 | # And the slot request is waiting the pod to be ready to return. 120 | sleep 20 121 | 122 | if ! check_katana "$project"; then 123 | cargo run -- d delete "$project" katana -f || true 124 | return 1 125 | fi 126 | 127 | cargo run -- d delete "$project" katana -f 128 | return 0 129 | } 130 | 131 | # Tests Torii deployment creation, tier update, config update and deletion. 132 | test_torii() { 133 | local project=$1 134 | local config_path=$2 135 | 136 | create_torii_config "$config_path" 137 | cargo run -- d create "$project" torii --config "$config_path" 138 | 139 | sleep 15 140 | 141 | if ! check_torii "$project"; then 142 | cargo run -- d delete "$project" torii -f || true 143 | return 1 144 | fi 145 | 146 | cargo run -- d update --tier epic "$project" torii 147 | # This delay is a bit higher, but it seems like Torii takes a bit longer to 148 | # start up in higher tiers. 149 | # Also, the update tier command returns directly, when it may need to wait for 150 | # the deployment to be ready. 151 | sleep 60 152 | 153 | if ! check_torii "$project"; then 154 | cargo run -- d delete "$project" torii -f || true 155 | return 1 156 | fi 157 | 158 | create_torii_config "$config_path" 159 | cargo run -- d update "$project" torii --config "$config_path" 160 | # Faster update, since it's only a restart with the new config. 161 | # And the slot request is waiting the pod to be ready to return. 162 | sleep 20 163 | 164 | if ! check_torii "$project"; then 165 | cargo run -- d delete "$project" torii -f || true 166 | return 1 167 | fi 168 | 169 | cargo run -- d delete "$project" torii -f 170 | return 0 171 | } 172 | 173 | # Main execution 174 | main() { 175 | local rand 176 | rand=$(date +%s) 177 | local project="slot-e2e-infra-$rand" 178 | local torii_config="/tmp/torii.toml" 179 | local katana_config="/tmp/katana.toml" 180 | 181 | if ! test_katana "$project" "$katana_config"; then 182 | echo "Katana test failed" 183 | exit 1 184 | fi 185 | 186 | if ! test_torii "$project" "$torii_config"; then 187 | echo "Torii test failed" 188 | exit 1 189 | fi 190 | 191 | echo "e2e tests passed" 192 | } 193 | 194 | main 195 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/info.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::paymaster::paymaster_info; 6 | use slot::graphql::paymaster::paymaster_info::PaymasterBudgetFeeUnit; 7 | use slot::graphql::paymaster::PaymasterInfo; 8 | use slot::graphql::GraphQLQuery; 9 | 10 | #[derive(Debug, Args)] 11 | #[command(next_help_heading = "Paymaster info options")] 12 | pub struct InfoArgs {} 13 | 14 | impl InfoArgs { 15 | pub async fn run(&self, name: String) -> Result<()> { 16 | let credentials = Credentials::load()?; 17 | 18 | let variables = paymaster_info::Variables { name: name.clone() }; 19 | let request_body = PaymasterInfo::build_query(variables); 20 | 21 | let client = Client::new_with_token(credentials.access_token); 22 | 23 | let data: paymaster_info::ResponseData = client.query(&request_body).await?; 24 | 25 | match data.paymaster { 26 | Some(paymaster) => { 27 | // Format budget with 2 decimal places by dividing by 1e6 28 | let budget_formatted = paymaster.budget as f64 / 1e6; 29 | let strk_fees_formatted = paymaster.strk_fees as f64 / 1e6; 30 | let credit_fees_formatted = paymaster.credit_fees as f64 / 1e6; 31 | 32 | // Calculate usage percentage and create progress bar 33 | let spent_amount = match paymaster.budget_fee_unit { 34 | PaymasterBudgetFeeUnit::STRK => strk_fees_formatted, 35 | PaymasterBudgetFeeUnit::CREDIT => credit_fees_formatted, 36 | _ => 0.0, 37 | }; 38 | 39 | let usage_percentage = if budget_formatted > 0.0 { 40 | (spent_amount / budget_formatted * 100.0).min(100.0) 41 | } else { 42 | 0.0 43 | }; 44 | 45 | // Create progress bar (40 characters wide) 46 | let bar_width = 30; 47 | let filled_width = (usage_percentage / 100.0 * bar_width as f64) as usize; 48 | let progress_bar = format!( 49 | "[{}{}]", 50 | "█".repeat(filled_width), 51 | "░".repeat(bar_width - filled_width) 52 | ); 53 | 54 | println!("\n🔍 Paymaster Info for '{}'", name); 55 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 56 | 57 | println!("🏢 Details:"); 58 | println!( 59 | " • Team: {}", 60 | paymaster 61 | .team 62 | .as_ref() 63 | .map(|t| t.name.as_str()) 64 | .unwrap_or("Unknown") 65 | ); 66 | println!( 67 | " • Active: {}", 68 | if paymaster.active { 69 | "✅ Yes" 70 | } else { 71 | "❌ No" 72 | } 73 | ); 74 | 75 | println!("\n💰 Budget:"); 76 | let usd_equivalent = match paymaster.budget_fee_unit { 77 | PaymasterBudgetFeeUnit::CREDIT => budget_formatted * 0.01, // 100 credit = 1 USD 78 | _ => 0.0, 79 | }; 80 | 81 | if usd_equivalent > 0.0 { 82 | println!(" • Total: ${:.2} USD", usd_equivalent); 83 | } else { 84 | println!(" • Total: NONE (Please Top Up)"); 85 | } 86 | 87 | // Only display the relevant fee type based on budget unit 88 | match paymaster.budget_fee_unit { 89 | PaymasterBudgetFeeUnit::STRK => { 90 | println!(" • Spent: {:.2} STRK", strk_fees_formatted); 91 | } 92 | PaymasterBudgetFeeUnit::CREDIT => { 93 | let spent_usd_equivalent = credit_fees_formatted * 0.01; // 100 credit = 1 USD 94 | println!(" • Spent: ${:.2} USD", spent_usd_equivalent); 95 | } 96 | _ => {} 97 | } 98 | 99 | // Display usage progress bar 100 | if budget_formatted > 0.0 { 101 | println!(" • Usage: {} {:.1}%", progress_bar, usage_percentage); 102 | } 103 | 104 | if paymaster.legacy_strk_fees > 0 || paymaster.legacy_eth_fees > 0 { 105 | let legacy_strk_formatted = paymaster.legacy_strk_fees as f64 / 1e6; 106 | let legacy_eth_formatted = paymaster.legacy_eth_fees as f64 / 1e6; 107 | println!("\n💸 Outstanding Balance:"); 108 | println!(" • This is the balance due prior to self service migration."); 109 | if paymaster.legacy_strk_fees > 0 { 110 | println!(" • Spent STRK: {:.2}", legacy_strk_formatted); 111 | } 112 | 113 | if paymaster.legacy_eth_fees > 0 { 114 | println!(" • Spent ETH: {:.4}", legacy_eth_formatted); 115 | } 116 | } 117 | 118 | println!("\n🧾 Lifetime Transactions:"); 119 | let total_successful = 120 | paymaster.successful_transactions + paymaster.legacy_successful_transactions; 121 | let total_reverted = 122 | paymaster.reverted_transactions + paymaster.legacy_reverted_transactions; 123 | println!(" • Total: {}", total_successful + total_reverted); 124 | println!(" • Successful: {}", total_successful); 125 | println!(" • Reverted: {}", total_reverted); 126 | 127 | println!("\n📋 Policies:"); 128 | println!(" • Count: {}", paymaster.policies.total_count); 129 | } 130 | None => { 131 | println!("Paymaster '{}' not found", name); 132 | } 133 | } 134 | 135 | Ok(()) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /cli/src/command/paymaster/budget.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Args, Subcommand}; 3 | use slot::api::Client; 4 | use slot::credential::Credentials; 5 | use slot::graphql::paymaster::decrease_budget::FeeUnit as DecreaseBudgetFeeUnit; 6 | use slot::graphql::paymaster::increase_budget::FeeUnit as IncreaseBudgetFeeUnit; 7 | use slot::graphql::paymaster::{decrease_budget, increase_budget}; 8 | use slot::graphql::paymaster::{DecreaseBudget, IncreaseBudget}; 9 | use slot::graphql::GraphQLQuery; 10 | 11 | #[derive(Debug, Args)] 12 | #[command(next_help_heading = "Paymaster budget options")] 13 | pub struct BudgetCmd { 14 | #[command(subcommand)] 15 | command: BudgetSubcommand, 16 | } 17 | 18 | #[derive(Subcommand, Debug)] 19 | enum BudgetSubcommand { 20 | #[command(about = "Increase the budget of a paymaster.")] 21 | Increase(IncreaseBudgetArgs), 22 | #[command(about = "Decrease the budget of a paymaster.")] 23 | Decrease(DecreaseBudgetArgs), 24 | } 25 | 26 | #[derive(Debug, Args)] 27 | struct IncreaseBudgetArgs { 28 | #[arg(long, help = "Amount to increase the budget.")] 29 | amount: u64, 30 | #[arg(long, help = "Unit for the budget (USD or STRK).")] 31 | unit: String, 32 | } 33 | 34 | #[derive(Debug, Args)] 35 | struct DecreaseBudgetArgs { 36 | #[arg(long, help = "Amount to decrease the budget.")] 37 | amount: u64, 38 | #[arg(long, help = "Unit for the budget (USD or STRK).")] 39 | unit: String, 40 | } 41 | 42 | impl BudgetCmd { 43 | pub async fn run(&self, name: String) -> Result<()> { 44 | match &self.command { 45 | BudgetSubcommand::Increase(args) => Self::run_increase(args, name.clone()).await, 46 | BudgetSubcommand::Decrease(args) => Self::run_decrease(args, name.clone()).await, 47 | } 48 | } 49 | 50 | async fn run_increase(args: &IncreaseBudgetArgs, name: String) -> Result<()> { 51 | let credentials = Credentials::load()?; 52 | 53 | let (unit, amount_for_api) = match args.unit.to_uppercase().as_str() { 54 | "USD" => (IncreaseBudgetFeeUnit::CREDIT, (args.amount * 100) as i64), // Convert USD to credits 55 | "STRK" => (IncreaseBudgetFeeUnit::STRK, args.amount as i64), 56 | _ => { 57 | return Err(anyhow::anyhow!( 58 | "Invalid unit: {}. Supported units: USD, STRK", 59 | args.unit 60 | )) 61 | } 62 | }; 63 | 64 | let variables = increase_budget::Variables { 65 | paymaster_name: name.clone(), 66 | amount: amount_for_api, 67 | unit, 68 | }; 69 | let request_body = IncreaseBudget::build_query(variables); 70 | 71 | let client = Client::new_with_token(credentials.access_token); 72 | 73 | let data: increase_budget::ResponseData = client.query(&request_body).await?; 74 | 75 | let new_budget_formatted = data.increase_budget.budget as f64 / 1e6; 76 | 77 | // Calculate display values based on original unit 78 | let display_budget = match args.unit.to_uppercase().as_str() { 79 | "USD" => format!("${:.2} USD", new_budget_formatted * 0.01), // Convert credits back to USD for display 80 | "STRK" => format!("{} STRK", new_budget_formatted as i64), 81 | _ => format!( 82 | "{} {}", 83 | new_budget_formatted as i64, 84 | args.unit.to_uppercase() 85 | ), 86 | }; 87 | 88 | println!("\n✅ Budget Increased Successfully"); 89 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 90 | 91 | println!("🏢 Paymaster: {}", data.increase_budget.name); 92 | 93 | println!("\n📈 Operation:"); 94 | println!(" • Action: Increased"); 95 | println!(" • Amount: {} {}", args.amount, args.unit.to_uppercase()); 96 | 97 | println!("\n💰 New Budget:"); 98 | println!(" • Amount: {}", display_budget); 99 | 100 | Ok(()) 101 | } 102 | 103 | async fn run_decrease(args: &DecreaseBudgetArgs, name: String) -> Result<()> { 104 | // 1. Load Credentials 105 | let credentials = Credentials::load()?; 106 | 107 | let (unit, amount_for_api) = match args.unit.to_uppercase().as_str() { 108 | "USD" => (DecreaseBudgetFeeUnit::CREDIT, (args.amount * 100) as i64), // Convert USD to credits 109 | "STRK" => (DecreaseBudgetFeeUnit::STRK, args.amount as i64), 110 | _ => { 111 | return Err(anyhow::anyhow!( 112 | "Invalid unit: {}. Supported units: USD, STRK", 113 | args.unit 114 | )) 115 | } 116 | }; 117 | 118 | // 2. Build Query Variables 119 | let variables = decrease_budget::Variables { 120 | paymaster_name: name.clone(), 121 | amount: amount_for_api, 122 | unit, 123 | }; 124 | let request_body = DecreaseBudget::build_query(variables); 125 | 126 | // 3. Create Client 127 | let client = Client::new_with_token(credentials.access_token); 128 | 129 | let data: decrease_budget::ResponseData = client.query(&request_body).await?; 130 | 131 | let new_budget_formatted = data.decrease_budget.budget as f64 / 1e6; 132 | 133 | // Calculate display values based on original unit 134 | let display_budget = match args.unit.to_uppercase().as_str() { 135 | "USD" => format!("${:.2} USD", new_budget_formatted * 0.01), // Convert credits back to USD for display 136 | "STRK" => format!("{} STRK", new_budget_formatted as i64), 137 | _ => format!( 138 | "{} {}", 139 | new_budget_formatted as i64, 140 | args.unit.to_uppercase() 141 | ), 142 | }; 143 | 144 | println!("\n✅ Budget Decreased Successfully"); 145 | println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 146 | 147 | println!("🏢 Paymaster: {}", data.decrease_budget.name); 148 | 149 | println!("\n📉 Operation:"); 150 | println!(" • Action: Decreased"); 151 | println!(" • Amount: {} {}", args.amount, args.unit.to_uppercase()); 152 | 153 | println!("\n💰 New Budget:"); 154 | println!(" • Amount: {}", display_budget); 155 | 156 | Ok(()) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /slot/src/credential.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::path::{Path, PathBuf}; 3 | use std::{env, fs}; 4 | 5 | use crate::account::AccountInfo; 6 | use crate::error::Error; 7 | use crate::utils::{self}; 8 | 9 | const CREDENTIALS_FILE: &str = "credentials.json"; 10 | 11 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 12 | pub struct AccessToken { 13 | pub token: String, 14 | pub r#type: String, 15 | } 16 | 17 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 18 | pub struct Credentials { 19 | pub account: AccountInfo, 20 | pub access_token: AccessToken, 21 | } 22 | 23 | impl Credentials { 24 | pub fn new(account: AccountInfo, access_token: AccessToken) -> Self { 25 | Self { 26 | account, 27 | access_token, 28 | } 29 | } 30 | 31 | /// Load the credentials of the currently authenticated user. 32 | /// 33 | /// # Errors 34 | /// 35 | /// This function will fail if no user has authenticated yet, or if 36 | /// the credentials file are invalid or missing. 37 | /// 38 | pub fn load() -> Result { 39 | Self::load_at(utils::config_dir()) 40 | } 41 | 42 | /// Store the credentials of an authenticated user. Returns the path to the stored credentials 43 | /// file. 44 | pub fn store(&self) -> Result { 45 | Self::store_at(utils::config_dir(), self) 46 | } 47 | 48 | pub(crate) fn store_at>( 49 | config_dir: P, 50 | credentials: &Self, 51 | ) -> Result { 52 | let path = get_file_path(config_dir); 53 | // create the dir paths if it doesn't yet exist 54 | fs::create_dir_all(path.parent().expect("qed; parent exist"))?; 55 | let content = serde_json::to_string_pretty(credentials)?; 56 | fs::write(&path, content)?; 57 | Ok(path) 58 | } 59 | 60 | pub(crate) fn load_at>(config_dir: P) -> Result { 61 | if let Ok(slot_auth) = env::var("SLOT_AUTH") { 62 | // Try parsing from environment variable first 63 | return serde_json::from_str::(&slot_auth) 64 | .map_err(|_| Error::MalformedCredentials); 65 | } 66 | 67 | // If environment variable is not set, try loading from file 68 | let path = get_file_path(config_dir); 69 | 70 | if !path.exists() { 71 | return Err(Error::Unauthorized); 72 | } 73 | 74 | let content = fs::read_to_string(&path)?; 75 | 76 | match serde_json::from_str::(&content) { 77 | Ok(credentials) => Ok(credentials), 78 | Err(_) => { 79 | // If parsing fails, delete the malformed file 80 | let _ = fs::remove_file(&path); // Ignore error during deletion 81 | Err(Error::MalformedCredentials) 82 | } 83 | } 84 | } 85 | } 86 | 87 | /// Get the path to the credentials file. 88 | pub fn get_file_path>(config_dir: P) -> PathBuf { 89 | config_dir.as_ref().join(CREDENTIALS_FILE) 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use assert_matches::assert_matches; 95 | use serde_json::{json, Value}; 96 | 97 | use crate::account::AccountInfo; 98 | use crate::credential::{AccessToken, Credentials, CREDENTIALS_FILE}; 99 | use crate::{utils, Error}; 100 | use std::{env, fs}; 101 | 102 | // This test is to make sure that changes made to the `Credentials` struct doesn't 103 | // introduce breaking changes to the serde format. 104 | #[test] 105 | fn test_rt_static_format() { 106 | let json = json!({ 107 | "account": { 108 | "id": "foo", 109 | "username": "username", 110 | "controllers": [ 111 | { 112 | "id": "foo", 113 | "address": "0x12345" 114 | } 115 | ], 116 | "credentials": [ 117 | { 118 | "id": "foobar", 119 | "publicKey": "mypublickey" 120 | } 121 | ] 122 | }, 123 | "access_token": { 124 | "token": "oauthtoken", 125 | "type": "bearer" 126 | } 127 | }); 128 | 129 | let credentials: Credentials = serde_json::from_value(json.clone()).unwrap(); 130 | 131 | assert_eq!(credentials.account.id, "foo".to_string()); 132 | assert_eq!(credentials.account.username, "username".to_string()); 133 | assert_eq!(credentials.account.credentials[0].id, "foobar"); 134 | assert_eq!(credentials.account.credentials[0].public_key, "mypublickey"); 135 | assert_eq!(credentials.access_token.token, "oauthtoken"); 136 | assert_eq!(credentials.access_token.r#type, "bearer"); 137 | 138 | let credentials_serialized: Value = serde_json::to_value(&credentials).unwrap(); 139 | assert_eq!(json, credentials_serialized); 140 | } 141 | 142 | #[test] 143 | fn loading_malformed_credentials() { 144 | // Clear SLOT_AUTH to ensure we're testing file-based credentials 145 | env::remove_var("SLOT_AUTH"); 146 | 147 | let malformed_cred = json!({ 148 | "access_token": "mytoken", 149 | "token_type": "mytokentype" 150 | }); 151 | 152 | let dir = utils::config_dir(); 153 | let path = dir.join(CREDENTIALS_FILE); 154 | fs::create_dir_all(&dir).expect("failed to create intermediary dirs"); 155 | fs::write(path, serde_json::to_vec(&malformed_cred).unwrap()).unwrap(); 156 | 157 | let result = Credentials::load_at(dir); 158 | assert_matches!(result, Err(Error::MalformedCredentials)) 159 | } 160 | 161 | #[test] 162 | fn loading_non_existent_credentials() { 163 | // Clear SLOT_AUTH to ensure we're testing file-based credentials 164 | env::remove_var("SLOT_AUTH"); 165 | 166 | let dir = utils::config_dir(); 167 | let err = Credentials::load_at(dir).unwrap_err(); 168 | assert!(err.to_string().contains("No credentials found")) 169 | } 170 | 171 | #[test] 172 | fn credentials_rt() { 173 | // Clear SLOT_AUTH to ensure we're testing file-based credentials 174 | env::remove_var("SLOT_AUTH"); 175 | 176 | let config_dir = utils::config_dir(); 177 | 178 | let access_token = AccessToken { 179 | token: "mytoken".to_string(), 180 | r#type: "Bearer".to_string(), 181 | }; 182 | 183 | let expected = Credentials::new(AccountInfo::default(), access_token); 184 | let _ = Credentials::store_at(&config_dir, &expected).unwrap(); 185 | 186 | let actual = Credentials::load_at(config_dir).unwrap(); 187 | assert_eq!(expected, actual); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /cli/src/command/rpc/logs.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table}; 4 | use serde_json::json; 5 | use slot::api::Client; 6 | use slot::credential::Credentials; 7 | use slot::graphql::rpc::list_rpc_logs::ResponseData; 8 | use std::time::SystemTime; 9 | 10 | use crate::command::paymaster::utils::parse_duration; 11 | 12 | #[derive(Debug, Args)] 13 | #[command(next_help_heading = "List RPC logs options")] 14 | pub struct LogsArgs { 15 | #[arg(long, help = "Team name to list logs for.")] 16 | team: String, 17 | 18 | #[arg( 19 | long, 20 | short = 'n', 21 | default_value = "10", 22 | help = "Number of logs to fetch (max 50)." 23 | )] 24 | limit: i64, 25 | 26 | #[arg(long, help = "Show logs after this cursor for pagination.")] 27 | after: Option, 28 | 29 | #[arg( 30 | long, 31 | short = 's', 32 | help = "Filter logs from the last duration (e.g., '30s', '5m', '2h', '1d'). Max 1 week." 33 | )] 34 | since: Option, 35 | } 36 | 37 | impl LogsArgs { 38 | pub async fn run(&self) -> Result<()> { 39 | // Validate limit is within bounds 40 | let limit = if self.limit > 50 { 41 | println!("Warning: Limit exceeds maximum of 50. Using 50 instead."); 42 | 50 43 | } else if self.limit < 1 { 44 | println!("Warning: Limit must be at least 1. Using 1 instead."); 45 | 1 46 | } else { 47 | self.limit 48 | }; 49 | 50 | // Build where filter if time filter is provided 51 | let where_filter = if let Some(ref since_str) = self.since { 52 | let duration = parse_duration(since_str)?; 53 | let since_time = SystemTime::now() 54 | .checked_sub(duration) 55 | .ok_or_else(|| anyhow::anyhow!("Time calculation overflow"))?; 56 | 57 | // Convert to RFC3339 timestamp for GraphQL 58 | let timestamp = chrono::DateTime::::from(since_time) 59 | .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); 60 | 61 | json!({ "timestampGTE": timestamp }) 62 | } else { 63 | json!(null) 64 | }; 65 | 66 | // Build the GraphQL query manually with JSON to support the where clause 67 | let request_body = json!({ 68 | "query": r#" 69 | query ListRpcLogs($teamName: String!, $first: Int, $after: Cursor, $where: RPCLogWhereInput) { 70 | rpcLogs(teamName: $teamName, first: $first, after: $after, where: $where) { 71 | edges { 72 | node { 73 | id 74 | teamID 75 | apiKeyID 76 | corsDomainID 77 | clientIP 78 | userAgent 79 | referer 80 | network 81 | method 82 | responseStatus 83 | responseSizeBytes 84 | durationMs 85 | isInternal 86 | costCredits 87 | timestamp 88 | processedAt 89 | } 90 | cursor 91 | } 92 | pageInfo { 93 | hasNextPage 94 | hasPreviousPage 95 | startCursor 96 | endCursor 97 | } 98 | totalCount 99 | } 100 | } 101 | "#, 102 | "variables": { 103 | "teamName": self.team, 104 | "first": limit, 105 | "after": self.after, 106 | "where": where_filter, 107 | } 108 | }); 109 | 110 | let user = Credentials::load()?; 111 | let client = Client::new_with_token(user.access_token); 112 | 113 | let data: ResponseData = client.query(&request_body).await?; 114 | 115 | if let Some(connection) = data.rpc_logs { 116 | if let Some(edges) = connection.edges { 117 | let logs: Vec<_> = edges 118 | .iter() 119 | .filter_map(|edge| edge.as_ref()) 120 | .filter_map(|edge| edge.node.as_ref()) 121 | .collect(); 122 | 123 | if logs.is_empty() { 124 | println!("\nNo RPC logs found for team '{}'", self.team); 125 | return Ok(()); 126 | } 127 | 128 | let mut table = Table::new(); 129 | table 130 | .load_preset(UTF8_FULL) 131 | .set_content_arrangement(ContentArrangement::Dynamic) 132 | .set_header(vec![ 133 | Cell::new("Timestamp"), 134 | Cell::new("Network"), 135 | Cell::new("Method"), 136 | Cell::new("Status"), 137 | Cell::new("Duration (ms)"), 138 | Cell::new("Size (bytes)"), 139 | Cell::new("API Key ID"), 140 | Cell::new("CORS Domain ID"), 141 | Cell::new("Client IP"), 142 | ]); 143 | 144 | for log in logs { 145 | table.add_row(vec![ 146 | Cell::new(&log.timestamp), 147 | Cell::new(format!("{:?}", log.network)), 148 | Cell::new(log.method.as_ref().map_or("-", |s| s.as_str())), 149 | Cell::new(log.response_status.to_string()), 150 | Cell::new(log.duration_ms.to_string()), 151 | Cell::new(log.response_size_bytes.to_string()), 152 | Cell::new(log.api_key_id.as_deref().unwrap_or("-")), 153 | Cell::new(log.cors_domain_id.as_deref().unwrap_or("-")), 154 | Cell::new(&log.client_ip), 155 | ]); 156 | } 157 | 158 | println!("\nRPC Logs for team '{}':", self.team); 159 | println!("{table}"); 160 | 161 | // Show pagination info if available 162 | let page_info = connection.page_info; 163 | if page_info.has_next_page { 164 | if let Some(end_cursor) = page_info.end_cursor { 165 | println!( 166 | "\nMore logs available. Use --after {} to see next page", 167 | end_cursor 168 | ); 169 | } 170 | } 171 | 172 | println!("\nTotal logs: {}", connection.total_count); 173 | } 174 | } 175 | 176 | Ok(()) 177 | } 178 | } 179 | --------------------------------------------------------------------------------