starknet-contract-verifier 0.3.1

Contract class verification tool that allows you to verify your starknet classes on a block explorer.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
use camino::Utf8PathBuf;
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::Url;
use scarb_metadata::{Metadata, MetadataCommand, MetadataCommandError};
use spdx::LicenseId;
use std::{env, fmt::Display, io, path::PathBuf};
use thiserror::Error;

use verifier::class_hash::ClassHash;

fn get_name_validation_regex() -> Result<&'static Regex, String> {
    lazy_static! {
        static ref VALID_NAME_REGEX: Result<Regex, regex::Error> = Regex::new(r"^[a-zA-Z0-9_-]+$");
    }

    match VALID_NAME_REGEX.as_ref() {
        Ok(regex) => Ok(regex),
        Err(_) => Err("Internal regex compilation error".to_string()),
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project(Metadata);

#[derive(Error, Debug)]
pub enum ProjectError {
    #[error("[E020] Scarb project manifest not found at: {0}\n\nSuggestions:\n  • Check that you're in a Scarb project directory\n  • Verify that Scarb.toml exists in the specified path\n  • Run 'scarb init' to create a new project\n  • Use --manifest-path to specify the correct path")]
    MissingManifest(Utf8PathBuf),

    #[error("[E021] Failed to read project metadata\n\nSuggestions:\n  • Check that Scarb.toml is valid TOML format\n  • Verify all dependencies are properly declared\n  • Run 'scarb check' to validate your project\n  • Ensure scarb is installed and up to date")]
    MetadataError(#[from] MetadataCommandError),

    #[error("[E022] File system error\n\nSuggestions:\n  • Check file permissions\n  • Verify the path exists and is accessible\n  • Ensure you have read access to the directory")]
    Io(#[from] io::Error),

    #[error("[E023] Path contains invalid UTF-8 characters\n\nSuggestions:\n  • Use only ASCII characters in file paths\n  • Avoid special characters in directory names\n  • Check for hidden or control characters in the path")]
    Utf8(#[from] camino::FromPathBufError),
}

impl ProjectError {
    pub const fn error_code(&self) -> &'static str {
        match self {
            Self::MissingManifest(_) => "E020",
            Self::MetadataError(_) => "E021",
            Self::Io(_) => "E022",
            Self::Utf8(_) => "E023",
        }
    }
}

#[allow(dead_code)]
impl Project {
    pub fn new(manifest: &Utf8PathBuf) -> Result<Self, ProjectError> {
        manifest.try_exists().map_err(|err| match err.kind() {
            io::ErrorKind::NotFound => ProjectError::MissingManifest(manifest.clone()),
            _ => ProjectError::from(err),
        })?;

        let root = manifest.parent().ok_or_else(|| {
            ProjectError::Io(io::Error::new(
                io::ErrorKind::NotFound,
                "Couldn't get parent directory of Scarb manifest file",
            ))
        })?;

        let metadata = MetadataCommand::new()
            .json()
            .manifest_path(manifest)
            .current_dir(root)
            .exec()?;

        Ok(Self(metadata))
    }

    pub const fn manifest_path(&self) -> &Utf8PathBuf {
        &self.0.workspace.manifest_path
    }

    pub const fn root_dir(&self) -> &Utf8PathBuf {
        &self.0.workspace.root
    }

    pub const fn metadata(&self) -> &Metadata {
        &self.0
    }

    pub fn get_license(&self) -> Option<LicenseId> {
        self.0.packages.first().and_then(|pkg| {
            pkg.manifest_metadata
                .license
                .as_ref()
                .and_then(|license_str| {
                    // Handle common SPDX identifiers directly
                    match license_str.as_str() {
                        "MIT" => spdx::license_id("MIT License"),
                        "Apache-2.0" => spdx::license_id("Apache License 2.0"),
                        "GPL-3.0" => spdx::license_id("GNU General Public License v3.0 only"),
                        "BSD-3-Clause" => spdx::license_id("BSD 3-Clause License"),
                        // Try exact match
                        _ => spdx::license_id(license_str).or_else(|| {
                            // Try imprecise matching
                            spdx::imprecise_license_id(license_str).map(|(lic, _)| lic)
                        }),
                    }
                })
        })
    }
}

impl Display for Project {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.manifest_path())
    }
}

pub fn project_value_parser(raw: &str) -> Result<Project, ProjectError> {
    let path = PathBuf::from(raw);

    let absolute = if path.is_absolute() {
        path
    } else {
        let mut cwd = env::current_dir()?;
        cwd.push(path);
        cwd
    };

    let utf8 = Utf8PathBuf::try_from(absolute)?;

    let manifest = if utf8.is_file() {
        utf8
    } else {
        utf8.join("Scarb.toml")
    };

    Project::new(&manifest)
}

#[derive(clap::Parser)]
#[command(name = "Starknet Contract Verifier")]
#[command(author = "Nethermind")]
#[command(version)]
#[command(about = "Verify Starknet smart contracts on block explorers")]
#[command(long_about = "
A command-line tool for verifying Starknet smart contracts on block explorers.

This tool allows you to verify that the source code of a deployed contract matches
the bytecode on the blockchain. It supports multiple networks (mainnet, testnet, custom)
and automatically handles project dependencies and source file collection.

Examples:
  # Verify a contract on mainnet
  starknet-contract-verifier --network mainnet verify --execute \\
    --class-hash 0x044dc2b3239382230d8b1e943df23b96f52eebcac93efe6e8bde92f9a2f1da18 \\
    --contract-name MyContract

  # Check verification status
  starknet-contract-verifier --network mainnet status --job job-id-here

  # Dry run (preview what would be submitted)
  starknet-contract-verifier --network mainnet verify \\
    --class-hash 0x044dc2b3239382230d8b1e943df23b96f52eebcac93efe6e8bde92f9a2f1da18 \\
    --contract-name MyContract
")]
pub struct Args {
    #[command(subcommand)]
    pub command: Commands,

    /// Network to verify on (mainnet, sepolia, or custom)
    #[arg(long, value_enum)]
    pub network: NetworkKind,

    #[command(flatten)]
    pub network_url: Network,
}

#[derive(clap::Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum Commands {
    /// Verify a smart contract against its deployed bytecode
    ///
    /// Submits the contract source code for verification against the deployed
    /// bytecode on the blockchain. By default performs a dry run showing what
    /// would be submitted. Use --execute to actually submit the verification.
    ///
    /// Example:
    ///   starknet-contract-verifier --network mainnet verify --execute \
    ///     --class-hash 0x044dc2b3239382230d8b1e943df23b96f52eebcac93efe6e8bde92f9a2f1da18 \
    ///     --contract-name `MyContract`
    Verify(VerifyArgs),

    /// Check the status of a verification job
    ///
    /// Queries the verification service for the current status of a submitted
    /// verification job. The job ID is returned when you submit a verification.
    ///
    /// Example:
    ///   starknet-contract-verifier --network mainnet status --job 12345678-1234-1234-1234-123456789012
    Status {
        /// Verification job ID (UUID format)
        #[arg(long, value_name = "UUID")]
        job: String,
    },
}

fn license_value_parser(license: &str) -> Result<LicenseId, String> {
    // First try for exact SPDX identifier match
    if let Some(id) = spdx::license_id(license) {
        return Ok(id);
    }

    // For common shorthand identifiers, try to map to the full name
    let mapped_license = match license {
        "MIT" => "MIT License",
        "Apache-2.0" => "Apache License 2.0",
        "GPL-3.0" => "GNU General Public License v3.0 only",
        "BSD-3-Clause" => "BSD 3-Clause License",
        _ => license,
    };

    // Try again with mapped name
    if let Some(id) = spdx::license_id(mapped_license) {
        return Ok(id);
    }

    // Try imprecise matching as a last resort
    if let Some((lic, _)) = spdx::imprecise_license_id(license) {
        return Ok(lic);
    }

    // Provide helpful error with suggestion if available
    let guess = spdx::imprecise_license_id(license)
        .map_or(String::new(), |(lic, _): (LicenseId, usize)| {
            format!(", do you mean: {}?", lic.name)
        });

    Err(format!("Unrecognized license: {license}{guess}"))
}

fn contract_name_value_parser(name: &str) -> Result<String, String> {
    // Check for minimum length
    if name.is_empty() {
        return Err("Contract name cannot be empty".to_string());
    }

    // Check for maximum length (reasonable limit)
    if name.len() > 100 {
        return Err("Contract name cannot exceed 100 characters".to_string());
    }

    // Check for valid characters: alphanumeric, underscore, hyphen
    let regex = get_name_validation_regex()?;
    if !regex.is_match(name) {
        return Err(
            "Contract name can only contain alphanumeric characters, underscores, and hyphens"
                .to_string(),
        );
    }

    // Check that it doesn't start with a hyphen or underscore
    if name.starts_with('-') || name.starts_with('_') {
        return Err("Contract name cannot start with a hyphen or underscore".to_string());
    }

    // Check that it doesn't end with a hyphen or underscore
    if name.ends_with('-') || name.ends_with('_') {
        return Err("Contract name cannot end with a hyphen or underscore".to_string());
    }

    // Additional security check: reject common system names
    let reserved_names = [
        "con", "aux", "prn", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
        "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
    ];
    if reserved_names.contains(&name.to_lowercase().as_str()) {
        return Err("Contract name cannot be a reserved system name".to_string());
    }

    Ok(name.to_string())
}

fn package_name_value_parser(name: &str) -> Result<String, String> {
    // Check for minimum length
    if name.is_empty() {
        return Err("Package name cannot be empty".to_string());
    }

    // Check for maximum length (reasonable limit)
    if name.len() > 100 {
        return Err("Package name cannot exceed 100 characters".to_string());
    }

    // Check for valid characters: alphanumeric, underscore, hyphen
    let regex = get_name_validation_regex()?;
    if !regex.is_match(name) {
        return Err(
            "Package name can only contain alphanumeric characters, underscores, and hyphens"
                .to_string(),
        );
    }

    Ok(name.to_string())
}

#[derive(clap::Args)]
pub struct VerifyArgs {
    /// Execute verification (otherwise performs dry run)
    #[arg(short = 'x', long, default_value_t = false)]
    pub execute: bool,

    /// Path to Scarb project directory (default: current directory)
    #[arg(
        long,
        value_name = "DIR",
        value_hint = clap::ValueHint::DirPath,
        value_parser = project_value_parser,
        default_value = "."
    )]
    pub path: Project,

    /// Class hash of the deployed contract to verify
    #[arg(
        long = "class-hash",
        value_name = "HASH",
        value_parser = ClassHash::new
    )]
    pub class_hash: ClassHash,

    /// Wait indefinitely for verification result (polls until completion)
    #[arg(long, default_value_t = false)]
    pub watch: bool,

    /// SPDX license identifier (e.g., MIT, Apache-2.0)
    #[arg(
        long,
        value_name = "SPDX",
        value_parser = license_value_parser,
    )]
    pub license: Option<LicenseId>,

    /// Name of the contract for verification
    #[arg(
        long = "contract-name",
        value_name = "NAME",
        value_parser = contract_name_value_parser
    )]
    pub contract_name: String,

    /// Select specific package for verification (required for workspace projects)
    #[arg(
        long,
        value_name = "PACKAGE_ID",
        value_parser = package_name_value_parser
    )]
    pub package: Option<String>,

    /// Include Scarb.lock file in verification submission
    #[arg(long, default_value_t = false)]
    pub lock_file: bool,

    /// Include test files from src/ directory in verification submission
    #[arg(long, default_value_t = false)]
    pub test_files: bool,
}

#[derive(clap::ValueEnum, Clone)]
pub enum NetworkKind {
    /// Target the Mainnet
    Mainnet,

    /// Target Sepolia testnet
    Sepolia,

    /// Target custom network
    Custom,
}

#[derive(Clone)]
pub struct Network {
    /// Custom public API address
    pub public: Url,

    /// Custom interval API address
    pub private: Url,
}

impl clap::FromArgMatches for Network {
    fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
        let public = matches
            .get_one::<Url>("public")
            .ok_or_else(|| {
                clap::Error::raw(
                    clap::error::ErrorKind::MissingRequiredArgument,
                    "Custom network API public URL is missing",
                )
            })?
            .clone();

        let private = matches
            .get_one::<Url>("private")
            .ok_or_else(|| {
                clap::Error::raw(
                    clap::error::ErrorKind::MissingRequiredArgument,
                    "Custom network API private URL is missing",
                )
            })?
            .clone();

        Ok(Self { public, private })
    }

    fn from_arg_matches_mut(matches: &mut clap::ArgMatches) -> Result<Self, clap::Error> {
        Self::from_arg_matches(matches)
    }

    fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
        let mut matches = matches.clone();
        self.update_from_arg_matches_mut(&mut matches)
    }

    fn update_from_arg_matches_mut(
        &mut self,
        matches: &mut clap::ArgMatches,
    ) -> Result<(), clap::Error> {
        self.public = matches
            .get_one::<Url>("public")
            .ok_or_else(|| {
                clap::Error::raw(
                    clap::error::ErrorKind::MissingRequiredArgument,
                    "Custom network API public URL is missing",
                )
            })?
            .clone();

        self.private = matches
            .get_one::<Url>("private")
            .ok_or_else(|| {
                clap::Error::raw(
                    clap::error::ErrorKind::MissingRequiredArgument,
                    "Custom network API private URL is missing",
                )
            })?
            .clone();

        Ok(())
    }
}

// Can't derive the default value logic, hence hand rolled instance
impl clap::Args for Network {
    fn augment_args(cmd: clap::Command) -> clap::Command {
        cmd.arg(
            clap::Arg::new("public")
                .long("public")
                .help("Custom public API address")
                .value_hint(clap::ValueHint::Url)
                .value_parser(Url::parse)
                .default_value_ifs([
                    ("network", "mainnet", "https://api.voyager.online/beta"),
                    (
                        "network",
                        "sepolia",
                        "https://sepolia-api.voyager.online/beta",
                    ),
                ])
                .required_if_eq("network", "custom"),
            // this would overwrite the defaults in _all_ the cases
            // .env("CUSTOM_PUBLIC_API_ENDPOINT_URL"),
        )
        .arg(
            clap::Arg::new("private")
                .long("private")
                .help("Custom interval API address")
                .value_hint(clap::ValueHint::Url)
                .value_parser(Url::parse)
                .default_value_ifs([
                    ("network", "mainnet", "https://voyager.online"),
                    ("network", "sepolia", "https://sepolia.voyager.online"),
                ])
                .required_if_eq("network", "custom"),
            // this would overwrite the defaults in _all_ the cases
            // .env("CUSTOM_INTERNAL_API_ENDPOINT_URL"),
        )
    }

    fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
        cmd.arg(
            clap::Arg::new("public")
                .long("public")
                .help("Custom public API address")
                .value_hint(clap::ValueHint::Url)
                .default_value_ifs([
                    ("network", "mainnet", "https://api.voyager.online/beta"),
                    (
                        "network",
                        "sepolia",
                        "https://sepolia-api.voyager.online/beta",
                    ),
                ])
                .required_if_eq("network", "custom"),
            // this would overwrite the defaults in _all_ the cases
            // .env("CUSTOM_PUBLIC_API_ENDPOINT_URL"),
        )
        .arg(
            clap::Arg::new("private")
                .long("private")
                .help("Custom interval API address")
                .value_hint(clap::ValueHint::Url)
                .default_value_ifs([
                    ("network", "mainnet", "https://api.voyager.online"),
                    ("network", "sepolia", "https://sepolia-api.voyager.online"),
                ])
                .required_if_eq("network", "custom"),
            // this would overwrite the defaults in _all_ the cases
            // .env("CUSTOM_INTERNAL_API_ENDPOINT_URL"),
        )
    }
}