diff --git a/CLAUDE.md b/CLAUDE.md index a53e45a..1d40e81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Related repos: - `stellar-registry/contracts` — the on-chain registry contracts this CLI talks to - `stellar-registry/ui` — registry frontend - `stellar-registry/indexer` — registry indexer & API -- `stellar-scaffold/cli` — the `stellar scaffold` CLI; publishes `stellar-build` and `stellar-scaffold-macro` (crates.io) that this repo depends on +- `stellar-scaffold/cli` — the `stellar scaffold` CLI; publishes `stellar-build` (crates.io) that this repo depends on ## Common Commands @@ -41,7 +41,10 @@ Note: the `justfile` still carries some recipes from the monorepo. Prefer the `c |-------|---------| | `stellar-registry-cli` | The `stellar registry` CLI: `publish`, `deploy`, `download`, `install`/`create-alias`, `upgrade`, `register-contract` | | `stellar-registry-build` | Library for interacting with the registry at build time | -| `stellar-registry` | Shared registry types and the `import_contract_client!` macro (published to crates.io; dev-dependency of `stellar-registry/contracts`) | +| `stellar-registry-macro` | Macro crate defining procedural macros `import_contract!`, `import_contract_client!`, and `import_asset`. Proc-macro crates are special and need to only export proc-macros. | +| `stellar-registry` | Re-exports the `import_contract!`, `import_contract_client!`, and `import_asset!` macros from `stellar-registry-macro`. Might export more behavior later. (published to crates.io; dev-dependency of `stellar-registry/contracts`) | +| `stellar-registry-name` | Defines standard name parsing/formatting used by `stellar-registry-macro` and `stellar-registry-cli` | +| `stellar-registry-test` | Unpublished testing tools used throughout this monorepo as a dev-dependency | ### CLI Command Flow @@ -59,7 +62,6 @@ Note: the `justfile` still carries some recipes from the monorepo. Prefer the `c ## Cross-repo dependencies This repo's crates depend on, from crates.io: -- `stellar-build` and `stellar-scaffold-macro` — published from `stellar-scaffold/cli` -- `stellar-scaffold-test` — pulled via git from `stellar-scaffold/cli` (it is `publish = false`); used in tests only +- `stellar-build` — published from `stellar-scaffold/cli` These are declared as workspace dependencies in the root `Cargo.toml`. If the registry CLI ever needs an unreleased change in one of these, bump and publish it from `stellar-scaffold/cli` first (or temporarily `[patch]` it locally). diff --git a/Cargo.lock b/Cargo.lock index c73a35b..ce51929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3929,7 +3929,7 @@ dependencies = [ "stellar-asset-spec", "stellar-rpc-client", "stellar-strkey 0.0.16", - "stellar-xdr 27.0.0", + "stellar-xdr", "strsim", "strum 0.17.1", "strum_macros 0.17.1", @@ -3968,7 +3968,7 @@ dependencies = [ "soroban-env-macros", "soroban-wasmi", "static_assertions", - "stellar-xdr 27.0.0", + "stellar-xdr", "wasmparser 0.116.1", ] @@ -4030,7 +4030,7 @@ dependencies = [ "quote", "serde", "serde_json", - "stellar-xdr 27.0.0", + "stellar-xdr", "syn 2.0.117", ] @@ -4088,7 +4088,7 @@ dependencies = [ "soroban-env-common", "soroban-spec", "soroban-spec-rust", - "stellar-xdr 27.0.0", + "stellar-xdr", "syn 2.0.117", ] @@ -4100,7 +4100,7 @@ checksum = "f85665c21947b7e9fff81a8e81c866e2e774040039856f75af9cb06ec75191c8" dependencies = [ "base64 0.22.1", "sha2 0.10.9", - "stellar-xdr 27.0.0", + "stellar-xdr", "thiserror 1.0.69", "wasmparser 0.116.1", ] @@ -4116,7 +4116,7 @@ dependencies = [ "quote", "sha2 0.10.9", "soroban-spec", - "stellar-xdr 27.0.0", + "stellar-xdr", "syn 2.0.117", "thiserror 1.0.69", ] @@ -4137,7 +4137,7 @@ dependencies = [ "serde_json", "soroban-spec", "stellar-strkey 0.0.16", - "stellar-xdr 27.0.0", + "stellar-xdr", "thiserror 1.0.69", "wasm-encoder 0.235.0", "wasmparser 0.116.1", @@ -4159,7 +4159,7 @@ dependencies = [ "serde_json", "sha2 0.9.9", "soroban-spec", - "stellar-xdr 27.0.0", + "stellar-xdr", "thiserror 1.0.69", ] @@ -4250,26 +4250,22 @@ dependencies = [ name = "stellar-registry" version = "0.0.11" dependencies = [ - "stellar-scaffold-macro", + "stellar-registry-macro", ] [[package]] name = "stellar-registry-build" version = "0.0.9" dependencies = [ - "dotenvy", - "ed25519-dalek", "expect-test", - "heck 0.5.0", + "semver", "sha2 0.10.9", - "shlex", "soroban-cli", - "soroban-spec-tools", "stellar-build", + "stellar-registry-name", "stellar-rpc-client", "stellar-strkey 0.0.16", "thiserror 2.0.18", - "tokio", ] [[package]] @@ -4300,6 +4296,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "stellar-registry-macro" +version = "0.0.1" +dependencies = [ + "proc-macro2", + "quote", + "sha2 0.10.9", + "stellar-build", + "stellar-registry-name", + "stellar-strkey 0.0.16", + "stellar-xdr", + "syn 2.0.117", +] + +[[package]] +name = "stellar-registry-name" +version = "0.0.1" +dependencies = [ + "expect-test", + "semver", + "thiserror 2.0.18", +] + [[package]] name = "stellar-registry-test" version = "0.1.0" @@ -4335,29 +4354,13 @@ dependencies = [ "serde_with", "sha2 0.10.9", "stellar-strkey 0.0.16", - "stellar-xdr 27.0.0", + "stellar-xdr", "termcolor", "termcolor_output", "thiserror 1.0.69", "tokio", ] -[[package]] -name = "stellar-scaffold-macro" -version = "0.8.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10db244165d775a521e6e73ee793cc7daafe5ad605b6550d8909b85b8b1745d" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "sha2 0.10.9", - "stellar-build", - "stellar-strkey 0.0.13", - "stellar-xdr 23.0.0", - "syn 2.0.117", -] - [[package]] name = "stellar-strkey" version = "0.0.13" @@ -4384,21 +4387,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "stellar-xdr" -version = "23.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" -dependencies = [ - "cfg_eval", - "crate-git-revision 0.0.6", - "escape-bytes", - "ethnum", - "hex", - "sha2 0.10.9", - "stellar-strkey 0.0.13", -] - [[package]] name = "stellar-xdr" version = "27.0.0" diff --git a/Cargo.toml b/Cargo.toml index c50dbc7..a6db099 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,12 @@ repository = "https://github.com/stellar-registry/cli" # Local crates stellar-registry = { path = "crates/stellar-registry" } stellar-registry-test = { path = "crates/stellar-registry-test" } +stellar-registry-macro = { path = "crates/stellar-registry-macro", version = "0.0.1" } +stellar-registry-build = { path = "crates/stellar-registry-build", version = "0.0.9" } +stellar-registry-name = { path = "crates/stellar-registry-name", version = "0.0.1" } -# Cross-repo deps from scaffold-stellar/cli (crates.io for published libs, -# git for stellar-scaffold-test which is `publish = false`). +# Cross-repo deps from scaffold-stellar/cli stellar-build = "0.0.6" -stellar-scaffold-macro = "0.8.14" stellar-cli = { version = "27.0.0", package = "soroban-cli", default-features = false } soroban-rpc = { package = "stellar-rpc-client", version = "27.0.0" } @@ -25,9 +26,14 @@ admin-sep = { git = "https://github.com/theahaco/admin-sep", rev = "c4624cc74291 stellar-xdr = "=27.0.0" stellar-strkey = "0.0.16" +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2", features = ["full"] } + cargo_metadata = "0.18.1" thiserror = "2.0.17" sha2 = "0.10.9" +semver = "1.0.28" clap = "4.6.1" reqwest = { version = "0.12.9", default-features = false } diff --git a/README.md b/README.md index 18d7190..ae53841 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,10 @@ the detailed command reference, configuration, and the mainnet workflow. |-------|---------| | [`stellar-registry-cli`](./crates/stellar-registry-cli) | The `stellar registry` CLI plugin | | [`stellar-registry-build`](./crates/stellar-registry-build) | Library for interacting with the registry at build time | -| [`stellar-registry`](./crates/stellar-registry) | Shared registry types and the `import_contract_client!` macro | +| [`stellar-registry-macro`](./crates/stellar-registry-macro) | Macro crate defining procedural macros `import_contract!`, `import_contract_client!`, and `import_asset`. [Proc-macro crates are special](https://www.reddit.com/r/rust/comments/tuxawv/why_do_procedural_macros_have_to_be_defined_in_a/) and need to only export proc-macros. | +| [`stellar-registry`](./crates/stellar-registry) | For use in Stellar smart contracts. Re-exports the `import_contract!`, `import_contract_client!`, and `import_asset!` macros from `stellar-registry-macro`. Might export more behavior later. | +| [`stellar-registry-name`](./crates/stellar-registry-name)| Defines standard name parsing/formatting used by `stellar-registry-macro` and `stellar-registry-cli`. | +| [`stellar-registry-test`](./crates/stellar-registry-test)| Unpublished testing tools used throughout this monorepo as a dev-dependency | ## What is the Contract Registry? diff --git a/crates/stellar-registry-build/Cargo.toml b/crates/stellar-registry-build/Cargo.toml index a5622ef..aed5a7c 100644 --- a/crates/stellar-registry-build/Cargo.toml +++ b/crates/stellar-registry-build/Cargo.toml @@ -15,20 +15,14 @@ crate-type = ["rlib"] [dependencies] stellar-cli = { workspace = true, default-features = false, features = [] } stellar-build = { workspace = true } +stellar-registry-name = { workspace = true } -soroban-spec-tools = { workspace = true } soroban-rpc = { workspace = true } stellar-strkey = { workspace = true } - -thiserror = "2.0.17" -tokio = { version = "1", features = ["full"] } -shlex = "1.1.0" -heck = "0.5.0" -ed25519-dalek = "2.2.0" sha2 = { workspace = true } -dotenvy = "0.15.7" -# soroban-rpc = "=20.3.3" +thiserror = { workspace = true } +semver = { workspace = true } [dev-dependencies] expect-test = "1.5" diff --git a/crates/stellar-registry-build/src/contract.rs b/crates/stellar-registry-build/src/contract.rs index dd00879..6c03725 100644 --- a/crates/stellar-registry-build/src/contract.rs +++ b/crates/stellar-registry-build/src/contract.rs @@ -1,4 +1,4 @@ -use crate::{Error, named_registry::PrefixedName, registry::Registry}; +use crate::{Error, name, registry::Registry}; use sha2::{Digest, Sha256}; use soroban_rpc as rpc; use stellar_build::Network; @@ -88,7 +88,13 @@ pub enum ContractId { Resolved(stellar_strkey::Contract), Unresolved(stellar_cli::config::UnresolvedContract), PreHash(PreHashContractID), - FromRegistry(PrefixedName), + FromRegistry(name::Prefixed), +} + +impl From for ContractId { + fn from(value: name::Prefixed) -> Self { + Self::FromRegistry(value) + } } impl ContractId { @@ -105,10 +111,10 @@ impl ContractId { ContractId::PreHash(pre_hash_contract_id) => { pre_hash_contract_id.id(&network_passphrase.parse()?) } - ContractId::FromRegistry(PrefixedName { channel, name }) => { - Registry::new(config, channel.as_deref()) + ContractId::FromRegistry(name) => { + Registry::new(config, name.channel()) .await? - .fetch_contract_id(name) + .fetch_contract_id(name.name()) .await? } }) diff --git a/crates/stellar-registry-build/src/error.rs b/crates/stellar-registry-build/src/error.rs index d20712a..6809f85 100644 --- a/crates/stellar-registry-build/src/error.rs +++ b/crates/stellar-registry-build/src/error.rs @@ -7,6 +7,8 @@ use stellar_cli::{ pub enum Error { #[error("Invalid contract id: {0}")] InvalidContractId(String), + #[error("contract `{0}` is flagged as compromised in the registry")] + ContractFlagged(String), #[error(transparent)] Invoke(#[from] invoke::Error), #[error(transparent)] @@ -15,4 +17,8 @@ pub enum Error { Locator(#[from] locator::Error), #[error(transparent)] Build(#[from] stellar_build::networks::Error), + #[error(transparent)] + Rpc(#[from] soroban_rpc::Error), + #[error(transparent)] + Xdr(#[from] stellar_cli::xdr::Error), } diff --git a/crates/stellar-registry-build/src/lib.rs b/crates/stellar-registry-build/src/lib.rs index 3ac574e..00602e7 100644 --- a/crates/stellar-registry-build/src/lib.rs +++ b/crates/stellar-registry-build/src/lib.rs @@ -1,6 +1,14 @@ +//! Registry interaction at build time. +//! +//! Talks to the on-chain registry over the network (`contract`, `registry`, +//! `error`), pulling in the full stellar-cli stack. Proc-macro crates that only +//! need typed registry names depend on the dependency-light `stellar-registry-name` +//! crate directly (re-exported here via the `name` module). + pub mod contract; pub mod error; -pub mod named_registry; pub mod registry; +pub mod name; + pub use error::Error; diff --git a/crates/stellar-registry-build/src/name.rs b/crates/stellar-registry-build/src/name.rs new file mode 100644 index 0000000..2f7c984 --- /dev/null +++ b/crates/stellar-registry-build/src/name.rs @@ -0,0 +1,22 @@ +pub use stellar_registry_name::*; + +mod cli { + use stellar_cli::config; + + use super::Prefixed; + use crate::registry::Registry; + + #[allow(async_fn_in_trait)] + pub trait RegistryAccess { + /// Resolve the (sub)registry this name's channel points at. + async fn registry(&self, config: &config::Args) -> Result; + } + + impl RegistryAccess for Prefixed { + async fn registry(&self, config: &config::Args) -> Result { + Registry::from_named_registry(config, self).await + } + } +} + +pub use cli::RegistryAccess; diff --git a/crates/stellar-registry-build/src/named_registry.rs b/crates/stellar-registry-build/src/named_registry.rs deleted file mode 100644 index 6312b15..0000000 --- a/crates/stellar-registry-build/src/named_registry.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::{convert::Infallible, fmt::Display, str::FromStr}; - -use stellar_cli::config; - -use crate::{Error, contract::ContractId, registry::Registry}; - -#[derive(Clone, Debug)] -/// Help docs for special type -pub struct PrefixedName { - pub channel: Option, - pub name: String, -} - -impl FromStr for PrefixedName { - type Err = Infallible; - - fn from_str(s: &str) -> Result { - if let Some((channel, name)) = s.split_once('/') { - Ok(Self { - channel: Some(channel.to_owned()), - name: name.to_owned(), - }) - } else { - Ok(Self { - channel: None, - name: s.to_owned(), - }) - } - } -} - -impl From for ContractId { - fn from(value: PrefixedName) -> Self { - Self::FromRegistry(value) - } -} - -impl PrefixedName { - pub async fn registry(&self, config: &config::Args) -> Result { - Registry::from_named_registry(config, self).await - } -} - -impl Display for PrefixedName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let PrefixedName { channel, name } = &self; - write!( - f, - "{}{name}", - channel - .as_ref() - .map(|channel| format!("{channel}/")) - .unwrap_or_default() - ) - } -} diff --git a/crates/stellar-registry-build/src/registry.rs b/crates/stellar-registry-build/src/registry.rs index e0f2a21..e139dc5 100644 --- a/crates/stellar-registry-build/src/registry.rs +++ b/crates/stellar-registry-build/src/registry.rs @@ -3,7 +3,7 @@ use stellar_cli::config; use crate::{ Error, contract::{Contract, PreHashContractID}, - named_registry::PrefixedName, + name, }; pub struct Registry(Contract); @@ -11,10 +11,11 @@ pub struct Registry(Contract); impl Registry { pub async fn from_named_registry( config: &config::Args, - name: &PrefixedName, + name: &name::Prefixed, ) -> Result { - Self::new(config, name.channel.as_deref()).await + Self::new(config, name.channel()).await } + pub async fn new(config: &config::Args, name: Option<&str>) -> Result { let contract = Self::verified(config)?; Ok(if let Some(name) = name { @@ -28,7 +29,23 @@ impl Registry { }) } + /// Fetch the deployed contract id for `name`, refusing to return it if the + /// contract is flagged as compromised in the registry. Callers that really + /// want a flagged id (e.g. behind a user-facing `--force`) must say so with + /// [`Self::fetch_contract_id_unchecked`]. pub async fn fetch_contract_id(&self, name: &str) -> Result { + if self.is_contract_flagged(name).await? { + return Err(Error::ContractFlagged(name.to_string())); + } + self.fetch_contract_id_unchecked(name).await + } + + /// Fetch the deployed contract id for `name` without the compromised-flag + /// check. Dangerous: prefer [`Self::fetch_contract_id`]. + pub async fn fetch_contract_id_unchecked( + &self, + name: &str, + ) -> Result { let slop = ["fetch_contract_id", "--contract-name", name]; let contract_id = self.0.invoke_with_result(&slop, true).await?; contract_id @@ -38,12 +55,54 @@ impl Registry { } pub async fn fetch_contract(&self, name: &str) -> Result { + // Unchecked on purpose: this resolves channel/subregistry contracts + // during `Registry::new`, before any user-facing `--force` flag can be + // consulted. The flagged-contract rejection is scoped to leaf + // contract-id lookups via `fetch_contract_id`. Ok(Contract::new( - self.fetch_contract_id(name).await?, + self.fetch_contract_id_unchecked(name).await?, self.0.config(), )) } + /// Is the named contract flagged as compromised in this (sub)registry? + /// + /// There is no on-chain getter for the flag, so read the raw persistent + /// `ContractEntry` ledger entry directly. It is keyed by + /// `(Symbol("CR"), )` and stored as a 2-tuple when + /// unflagged and a 3-tuple (with a trailing `Void` sentinel) when flagged — + /// the vec length carries the flag. Mirrors the registry contract's + /// `ContractKey` / `ContractEntry` (stellar-registry/contracts + /// `src/storage.rs`); coupled to that encoding by design. + pub async fn is_contract_flagged(&self, name: &str) -> Result { + use stellar_cli::xdr; + let canonical = name::canonicalize(name); + let key = xdr::ScVal::Vec(Some( + vec![ + xdr::ScVal::Symbol(xdr::ScSymbol("CR".try_into()?)), + xdr::ScVal::String(xdr::ScString(canonical.as_str().try_into()?)), + ] + .try_into()?, + )); + let ledger_key = xdr::LedgerKey::ContractData(xdr::LedgerKeyContractData { + contract: self.0.sc_address(), + key, + durability: xdr::ContractDataDurability::Persistent, + }); + let entries = self + .0 + .rpc_client()? + .get_full_ledger_entries(&[ledger_key]) + .await? + .entries; + Ok(entries.into_iter().any(|e| match e.val { + xdr::LedgerEntryData::ContractData(cd) => { + matches!(cd.val, xdr::ScVal::Vec(Some(v)) if v.len() == 3) + } + _ => false, + })) + } + pub fn as_contract(&self) -> &Contract { &self.0 } diff --git a/crates/stellar-registry-cli/src/commands/create_alias.rs b/crates/stellar-registry-cli/src/commands/create_alias.rs index 7de23c9..e445328 100644 --- a/crates/stellar-registry-cli/src/commands/create_alias.rs +++ b/crates/stellar-registry-cli/src/commands/create_alias.rs @@ -1,7 +1,7 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::named_registry::PrefixedName; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use stellar_strkey::Contract; use crate::commands::global; @@ -10,12 +10,13 @@ use crate::commands::global; pub struct Cmd { /// Name of deployed contract. Can use prefix if not using verified registry. /// E.g. `unverified/` - pub contract: PrefixedName, + pub contract: Prefixed, /// Optional custom local name for the alias. If not provided, uses the name from the registry. pub local_name: Option, - /// Force overwrite if an alias with the same name already exists. + /// Force overwrite if an alias with the same name already exists, and + /// allow aliasing a contract flagged as compromised in the registry. #[arg(short, long)] pub force: bool, @@ -38,13 +39,17 @@ pub enum Error { #[error( "Existing alias \"{1}\" exists. Overwrite with -f or provide a different local name like: \"create-alias {0} other-{1}\"." )] - AliasExists(PrefixedName, String), + AliasExists(Prefixed, String), + #[error( + "contract `{0}` is flagged as compromised in the registry; pass --force to create the alias anyway" + )] + ContractFlagged(String), } impl Cmd { pub async fn run(&self) -> Result<(), Error> { let network_passphrase = self.config.get_network()?.network_passphrase; - let alias = self.local_name.as_deref().unwrap_or(&self.contract.name); + let alias = self.local_name.as_deref().unwrap_or(self.contract.name()); let contract = self.get_contract_id().await?; // Check if alias already exists @@ -69,7 +74,20 @@ impl Cmd { pub async fn get_contract_id(&self) -> Result { let registry = &self.contract.registry(&self.config).await?; eprintln!("Fetching contract ID via registry..."); - Ok(registry.fetch_contract_id(&self.contract.name).await?) + if self.force { + return Ok(registry + .fetch_contract_id_unchecked(self.contract.name()) + .await?); + } + registry + .fetch_contract_id(self.contract.name()) + .await + .map_err(|e| match e { + stellar_registry_build::Error::ContractFlagged(_) => { + Error::ContractFlagged(self.contract.to_string()) + } + other => other.into(), + }) } } diff --git a/crates/stellar-registry-cli/src/commands/current_version.rs b/crates/stellar-registry-cli/src/commands/current_version.rs index 70bc349..653ccd8 100644 --- a/crates/stellar-registry-cli/src/commands/current_version.rs +++ b/crates/stellar-registry-cli/src/commands/current_version.rs @@ -1,13 +1,13 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::named_registry::PrefixedName; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; #[derive(Parser, Debug, Clone)] pub struct Cmd { /// Name of published Wasm - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, #[command(flatten)] pub config: global::Args, @@ -32,7 +32,7 @@ impl Cmd { pub async fn current_version(&self) -> Result { let registry = self.wasm_name.registry(&self.config).await?; - let slop = ["current_version", "--wasm-name", &self.wasm_name.name]; + let slop = ["current_version", "--wasm-name", self.wasm_name.name()]; let raw = registry .as_contract() .invoke_with_result(&slop, true) diff --git a/crates/stellar-registry-cli/src/commands/deploy.rs b/crates/stellar-registry-cli/src/commands/deploy.rs index 65012d7..a67a868 100644 --- a/crates/stellar-registry-cli/src/commands/deploy.rs +++ b/crates/stellar-registry-cli/src/commands/deploy.rs @@ -12,7 +12,10 @@ use stellar_cli::{ utils::rpc::get_remote_wasm_from_hash, xdr::{self, AccountId, InvokeContractArgs, ScSpecEntry, ScString, ScVal, Uint256}, }; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{ + name::{Prefixed, RegistryAccess}, + registry::Registry, +}; use crate::commands::global; @@ -23,11 +26,11 @@ pub struct Cmd { /// Name of contract to be deployed. Can use prefix of not using verified registry. /// E.g. `unverified/` #[arg(long, visible_alias = "deploy-as")] - pub contract_name: PrefixedName, + pub contract_name: Prefixed, /// Name of published contract to deploy from. Can use prefix of not using verified registry. /// E.g. `unverified/` #[arg(long)] - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, /// Arguments for constructor #[arg(last = true, id = "CONSTRUCTOR_ARGS")] pub slop: Vec, @@ -90,7 +93,7 @@ impl Cmd { Ok(contract_id) => { println!( "Contract {} deployed successfully to {contract_id}", - self.contract_name.name + self.contract_name.name() ); Ok(()) } @@ -105,7 +108,7 @@ impl Cmd { pub async fn hash(&self, registry: &Registry) -> Result { let res = registry .as_contract() - .invoke_with_result(&["fetch_hash", "--wasm_name", &self.wasm_name.name], true) + .invoke_with_result(&["fetch_hash", "--wasm_name", self.wasm_name.name()], true) .await?; let res = res.trim_matches('"'); Ok(res.parse().unwrap()) @@ -143,13 +146,11 @@ impl Cmd { None }; let mut call_args: Vec = vec![ - ScVal::String(ScString(self.wasm_name.name.clone().try_into().unwrap())), + ScVal::String(ScString(self.wasm_name.name().try_into().unwrap())), self.version.clone().map_or(ScVal::Void, |s| { ScVal::String(ScString(s.try_into().unwrap())) }), - ScVal::String(ScString( - self.contract_name.name.clone().try_into().unwrap(), - )), + ScVal::String(ScString(self.contract_name.name().try_into().unwrap())), ScVal::Address(xdr::ScAddress::Account(AccountId( xdr::PublicKey::PublicKeyTypeEd25519(Uint256(key.verifying_key().to_bytes())), ))), @@ -163,11 +164,7 @@ impl Cmd { // the trusted root pinned at construction, so we pass the name // rather than an address. Root has no prefix — its own name in // root storage is "registry". - let subregistry_name = self - .wasm_name - .channel - .clone() - .unwrap_or_else(|| "registry".to_string()); + let subregistry_name = self.wasm_name.channel().unwrap_or("registry"); call_args.push(ScVal::String(ScString( subregistry_name.try_into().unwrap(), ))); diff --git a/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs b/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs index 6574806..55797ba 100644 --- a/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs +++ b/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs @@ -12,7 +12,10 @@ use stellar_cli::{ utils::rpc::get_remote_wasm_from_hash, xdr::{self, InvokeContractArgs, ScSpecEntry, ScString, ScVal, Uint256}, }; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{ + name::{Prefixed, RegistryAccess}, + registry::Registry, +}; use crate::commands::global; @@ -23,7 +26,7 @@ pub struct Cmd { /// Name of published wasm to deploy from. Can use prefix if not using verified registry. /// E.g. `unverified/` #[arg(long)] - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, /// Arguments for constructor #[arg(last = true, id = "CONSTRUCTOR_ARGS")] @@ -89,7 +92,7 @@ impl Cmd { } pub async fn hash(&self, registry: &Registry) -> Result { - let mut slop = vec!["fetch_hash", "--wasm_name", &self.wasm_name.name]; + let mut slop = vec!["fetch_hash", "--wasm_name", &self.wasm_name.name()]; let version = self.version.clone().map(|v| format!("\"{v}\"")); if let Some(version) = version.as_deref() { slop.push("--version"); @@ -149,7 +152,7 @@ impl Cmd { .unwrap(), )); let args: [ScVal; 5] = [ - ScVal::String(ScString(self.wasm_name.name.clone().try_into().unwrap())), + ScVal::String(ScString(self.wasm_name.name().try_into().unwrap())), self.version.clone().map_or(ScVal::Void, |s| { ScVal::String(ScString(s.try_into().unwrap())) }), diff --git a/crates/stellar-registry-cli/src/commands/download.rs b/crates/stellar-registry-cli/src/commands/download.rs index fa69345..c39549e 100644 --- a/crates/stellar-registry-cli/src/commands/download.rs +++ b/crates/stellar-registry-cli/src/commands/download.rs @@ -2,14 +2,14 @@ use std::{io::Write, path::PathBuf}; use clap::Parser; use stellar_cli::{commands::contract::invoke, xdr}; -use stellar_registry_build::named_registry::PrefixedName; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; #[derive(Parser, Debug, Clone)] pub struct Cmd { /// Name of published Wasm - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, /// Version of published Wasm, if not specified, the latest version will be fetched #[arg(long)] @@ -62,7 +62,7 @@ impl Cmd { pub async fn download_bytes(&self) -> Result, Error> { let registry = &self.wasm_name.registry(&self.config).await?; - let mut slop = vec!["fetch_hash", "--wasm-name", &self.wasm_name.name]; + let mut slop = vec!["fetch_hash", "--wasm-name", &self.wasm_name.name()]; let version = self.version.clone().map(|v| format!("\"{v}\"")); if let Some(version) = version.as_deref() { slop.push("--version"); diff --git a/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs b/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs index c33a883..c7e9c8f 100644 --- a/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs +++ b/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::named_registry::PrefixedName; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use stellar_strkey::Contract; use crate::commands::global; @@ -9,7 +9,12 @@ use crate::commands::global; pub struct Cmd { /// Name of deployed contract. Can use prefix if not using verified registry. /// E.g. `unverified/` - pub contract_name: PrefixedName, + pub contract_name: Prefixed, + + /// Return the id even if the contract is flagged as compromised in the + /// registry. Without this, flagged contracts fail with a non-zero exit. + #[arg(long)] + pub force: bool, #[command(flatten)] pub config: global::Args, @@ -23,6 +28,10 @@ pub enum Error { Config(#[from] stellar_cli::config::Error), #[error(transparent)] Registry(#[from] stellar_registry_build::Error), + #[error( + "contract `{0}` is flagged as compromised in the registry; pass --force to fetch its id anyway" + )] + ContractFlagged(String), } impl Cmd { @@ -34,7 +43,20 @@ impl Cmd { pub async fn fetch_contract_id(&self) -> Result { let registry = self.contract_name.registry(&self.config).await?; - Ok(registry.fetch_contract_id(&self.contract_name.name).await?) + if self.force { + return Ok(registry + .fetch_contract_id_unchecked(self.contract_name.name()) + .await?); + } + registry + .fetch_contract_id(self.contract_name.name()) + .await + .map_err(|e| match e { + stellar_registry_build::Error::ContractFlagged(_) => { + Error::ContractFlagged(self.contract_name.to_string()) + } + other => other.into(), + }) } } diff --git a/crates/stellar-registry-cli/src/commands/fetch_hash.rs b/crates/stellar-registry-cli/src/commands/fetch_hash.rs index 3508201..b59aba4 100644 --- a/crates/stellar-registry-cli/src/commands/fetch_hash.rs +++ b/crates/stellar-registry-cli/src/commands/fetch_hash.rs @@ -1,13 +1,13 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::named_registry::PrefixedName; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; #[derive(Parser, Debug, Clone)] pub struct Cmd { /// Name of published Wasm - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, /// Version of published Wasm, if not specified, the latest version will be fetched #[arg(long)] @@ -36,7 +36,7 @@ impl Cmd { pub async fn fetch_hash(&self) -> Result { let registry = self.wasm_name.registry(&self.config).await?; - let mut slop = vec!["fetch_hash", "--wasm-name", &self.wasm_name.name]; + let mut slop = vec!["fetch_hash", "--wasm-name", &self.wasm_name.name()]; let version = self.version.clone().map(|v| format!("\"{v}\"")); if let Some(version) = version.as_deref() { slop.push("--version"); diff --git a/crates/stellar-registry-cli/src/commands/publish.rs b/crates/stellar-registry-cli/src/commands/publish.rs index b5acfb8..9bffd94 100644 --- a/crates/stellar-registry-cli/src/commands/publish.rs +++ b/crates/stellar-registry-cli/src/commands/publish.rs @@ -8,7 +8,7 @@ use stellar_cli::{ config, xdr::{ScMetaEntry, ScMetaV0}, }; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{name::Prefixed, registry::Registry}; use crate::{commands::global, github::Fetcher}; @@ -32,7 +32,7 @@ pub struct Cmd { pub author: Option, /// Wasm name, if not provided, will try to extract from contract metadata #[arg(long, requires = "from_github")] - pub wasm_name: Option, + pub wasm_name: Option, /// Wasm binary version, if not provided, will try to extract from contract metadata #[arg(long, requires = "from_github")] pub binver: Option, @@ -75,9 +75,15 @@ pub enum Error { impl Cmd { pub async fn get_wasm_bytes(&self) -> Result, Error> { if let Some(github) = &self.wasm_args.from_github { + let package = self + .wasm_name + .as_ref() + .ok_or(Error::WasmNameMissing)? + .name() + .to_string(); Ok(Fetcher::new( github, - &self.wasm_name.as_ref().ok_or(Error::WasmNameMissing)?.name, + &package, self.binver.as_ref().ok_or(Error::BinverMissing)?, ) .fetch() @@ -120,8 +126,8 @@ impl Cmd { })); // Add wasm_name if specified - if let Some(PrefixedName { name, .. }) = self.wasm_name.as_ref() { - args.push(format!("--wasm_name={name}")); + if let Some(wasm_name) = self.wasm_name.as_ref() { + args.push(format!("--wasm_name={}", wasm_name.name())); } // Add version if specified @@ -138,7 +144,7 @@ impl Cmd { args.push(format!("--author={author}")); let registry = Registry::new( &self.config, - self.wasm_name.as_ref().and_then(|p| p.channel.as_deref()), + self.wasm_name.as_ref().and_then(|p| p.channel()), ) .await?; registry diff --git a/crates/stellar-registry-cli/src/commands/publish_hash.rs b/crates/stellar-registry-cli/src/commands/publish_hash.rs index 4dbc7f6..262eea8 100644 --- a/crates/stellar-registry-cli/src/commands/publish_hash.rs +++ b/crates/stellar-registry-cli/src/commands/publish_hash.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::{commands::contract::invoke, config}; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{name::Prefixed, registry::Registry}; use crate::commands::global; @@ -12,7 +12,7 @@ pub struct Cmd { /// Wasm name #[arg(long)] - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, /// Version string (e.g. "0.0.1") #[arg(long)] @@ -51,7 +51,7 @@ impl Cmd { let args = [ "publish_hash", "--wasm_name", - &self.wasm_name.name, + self.wasm_name.name(), "--author", &author, "--wasm_hash", @@ -60,7 +60,7 @@ impl Cmd { &self.version, ]; - let registry = Registry::new(&self.config, self.wasm_name.channel.as_deref()).await?; + let registry = Registry::new(&self.config, self.wasm_name.channel()).await?; registry.as_contract().invoke(&args, self.dry_run).await?; @@ -68,7 +68,7 @@ impl Cmd { "{}Successfully published hash {} as {}@{}", if self.dry_run { "Dry Run: " } else { "" }, self.wasm_hash, - self.wasm_name.name, + self.wasm_name.name(), self.version ); Ok(()) diff --git a/crates/stellar-registry-cli/src/commands/register_contract.rs b/crates/stellar-registry-cli/src/commands/register_contract.rs index 1646675..f249b3f 100644 --- a/crates/stellar-registry-cli/src/commands/register_contract.rs +++ b/crates/stellar-registry-cli/src/commands/register_contract.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::{commands::contract::invoke, config}; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{name::Prefixed, registry::Registry}; use crate::commands::global; @@ -9,7 +9,7 @@ pub struct Cmd { /// Name to register for the contract. Can use prefix if not using verified registry. /// E.g. `unverified/` #[arg(long)] - pub contract_name: PrefixedName, + pub contract_name: Prefixed, /// Contract address to register #[arg(long)] @@ -48,21 +48,21 @@ impl Cmd { let args = [ "register_contract", "--contract_name", - &self.contract_name.name, + self.contract_name.name(), "--contract_address", &self.contract_address, "--owner", &owner, ]; - let registry = Registry::new(&self.config, self.contract_name.channel.as_deref()).await?; + let registry = Registry::new(&self.config, self.contract_name.channel()).await?; registry.as_contract().invoke(&args, self.dry_run).await?; eprintln!( "{}Successfully registered contract '{}' at {}", if self.dry_run { "Dry Run: " } else { "" }, - self.contract_name.name, + self.contract_name.name(), self.contract_address ); Ok(()) diff --git a/crates/stellar-registry-cli/src/commands/rename_contract.rs b/crates/stellar-registry-cli/src/commands/rename_contract.rs index bd32275..a46104b 100644 --- a/crates/stellar-registry-cli/src/commands/rename_contract.rs +++ b/crates/stellar-registry-cli/src/commands/rename_contract.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::{commands::contract::invoke, config}; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{name::Prefixed, registry::Registry}; use crate::commands::global; @@ -8,7 +8,7 @@ use crate::commands::global; pub struct Cmd { /// Current name of the registered contract #[arg(long)] - pub contract_name: PrefixedName, + pub contract_name: Prefixed, /// New name for the contract #[arg(long)] @@ -34,12 +34,12 @@ pub enum Error { impl Cmd { pub async fn run(&self) -> Result<(), Error> { - let registry = Registry::new(&self.config, self.contract_name.channel.as_deref()).await?; + let registry = Registry::new(&self.config, self.contract_name.channel()).await?; let args = [ "rename_contract", "--old_name", - &self.contract_name.name, + self.contract_name.name(), "--new_name", &self.new_name, ]; @@ -49,7 +49,7 @@ impl Cmd { eprintln!( "{}Successfully renamed '{}' to '{}'", if self.dry_run { "Dry Run: " } else { "" }, - self.contract_name.name, + self.contract_name.name(), self.new_name ); Ok(()) diff --git a/crates/stellar-registry-cli/src/commands/update_contract_address.rs b/crates/stellar-registry-cli/src/commands/update_contract_address.rs index 7611e57..011fb3e 100644 --- a/crates/stellar-registry-cli/src/commands/update_contract_address.rs +++ b/crates/stellar-registry-cli/src/commands/update_contract_address.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::{commands::contract::invoke, config}; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{name::Prefixed, registry::Registry}; use crate::commands::global; @@ -8,7 +8,7 @@ use crate::commands::global; pub struct Cmd { /// Name of the registered contract #[arg(long)] - pub contract_name: PrefixedName, + pub contract_name: Prefixed, /// New contract address #[arg(long)] @@ -34,12 +34,12 @@ pub enum Error { impl Cmd { pub async fn run(&self) -> Result<(), Error> { - let registry = Registry::new(&self.config, self.contract_name.channel.as_deref()).await?; + let registry = Registry::new(&self.config, self.contract_name.channel()).await?; let args = [ "update_contract_address", "--contract_name", - &self.contract_name.name, + self.contract_name.name(), "--new_address", &self.new_address, ]; @@ -49,7 +49,7 @@ impl Cmd { eprintln!( "{}Successfully updated address of '{}' to {}", if self.dry_run { "Dry Run: " } else { "" }, - self.contract_name.name, + self.contract_name.name(), self.new_address ); Ok(()) diff --git a/crates/stellar-registry-cli/src/commands/update_contract_owner.rs b/crates/stellar-registry-cli/src/commands/update_contract_owner.rs index 2c0ed18..e8a9196 100644 --- a/crates/stellar-registry-cli/src/commands/update_contract_owner.rs +++ b/crates/stellar-registry-cli/src/commands/update_contract_owner.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::{commands::contract::invoke, config}; -use stellar_registry_build::{named_registry::PrefixedName, registry::Registry}; +use stellar_registry_build::{name::Prefixed, registry::Registry}; use crate::commands::global; @@ -8,7 +8,7 @@ use crate::commands::global; pub struct Cmd { /// Name of the registered contract #[arg(long)] - pub contract_name: PrefixedName, + pub contract_name: Prefixed, /// New owner address #[arg(long)] @@ -34,12 +34,12 @@ pub enum Error { impl Cmd { pub async fn run(&self) -> Result<(), Error> { - let registry = Registry::new(&self.config, self.contract_name.channel.as_deref()).await?; + let registry = Registry::new(&self.config, self.contract_name.channel()).await?; let args = [ "update_contract_owner", "--contract_name", - &self.contract_name.name, + self.contract_name.name(), "--new_owner", &self.new_owner, ]; @@ -49,7 +49,7 @@ impl Cmd { eprintln!( "{}Successfully updated owner of '{}' to {}", if self.dry_run { "Dry Run: " } else { "" }, - self.contract_name.name, + self.contract_name.name(), self.new_owner ); Ok(()) diff --git a/crates/stellar-registry-cli/src/commands/upgrade.rs b/crates/stellar-registry-cli/src/commands/upgrade.rs index fe5dc44..ae6a2d3 100644 --- a/crates/stellar-registry-cli/src/commands/upgrade.rs +++ b/crates/stellar-registry-cli/src/commands/upgrade.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::named_registry::PrefixedName; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; @@ -9,12 +9,12 @@ pub struct Cmd { /// Name of contract to upgrade. Can use prefix of not using verified registry. /// E.g. `unverified/` #[arg(long)] - pub contract_name: PrefixedName, + pub contract_name: Prefixed, /// Name of published Wasm. Can use prefix of not using verified registry. /// E.g. `unverified/` #[arg(long)] - pub wasm_name: PrefixedName, + pub wasm_name: Prefixed, /// Version of published Wasm, if not specified, the latest version will be fetched #[arg(long)] @@ -44,8 +44,8 @@ pub enum Error { impl Cmd { pub async fn run(&self) -> Result<(), Error> { - let contract_name = &self.contract_name.name; - let wasm_name = &self.wasm_name.name; + let contract_name = &self.contract_name.name(); + let wasm_name = &self.wasm_name.name(); let mut slop = vec![ "upgrade_contract", diff --git a/crates/stellar-registry-macro/Cargo.toml b/crates/stellar-registry-macro/Cargo.toml new file mode 100644 index 0000000..65b5661 --- /dev/null +++ b/crates/stellar-registry-macro/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "stellar-registry-macro" +version = "0.0.1" +edition = "2024" +description = "Macro crate defining the proc-macros that get re-exported by stellar-registry" +license = "Apache-2.0" +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } +stellar-build = { workspace = true } +# name types only — the dependency-light name crate keeps this proc-macro from +# dragging the stellar-cli stack into consumer contract builds. +stellar-registry-name = { workspace = true } +stellar-strkey = { workspace = true } +stellar-xdr = { workspace = true } +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/stellar-registry-macro/README.md b/crates/stellar-registry-macro/README.md new file mode 100644 index 0000000..22ce223 --- /dev/null +++ b/crates/stellar-registry-macro/README.md @@ -0,0 +1,6 @@ +# stellar-registry-macro + +The [macro crate] defining `import_contract!`, `import_contract_client!`, and `import_asset!` which get re-exported by the [stellar-registry] crate. + + [macro crate]: https://www.reddit.com/r/rust/comments/tuxawv/why_do_procedural_macros_have_to_be_defined_in_a/ + [stellar-registry]: https://crates.io/crates/stellar-registry diff --git a/crates/stellar-registry-macro/src/asset.rs b/crates/stellar-registry-macro/src/asset.rs new file mode 100644 index 0000000..84afa48 --- /dev/null +++ b/crates/stellar-registry-macro/src/asset.rs @@ -0,0 +1,242 @@ +use proc_macro2::TokenStream; +use sha2::{Digest, Sha256}; + +use stellar_build::Network; +use stellar_xdr as xdr; +use xdr::WriteXdr; + +use quote::quote; +use syn::LitStr; + +pub(crate) fn import_asset(input: proc_macro::TokenStream) -> syn::Result { + let lit: LitStr = syn::parse(input)?; + parse_literal(&lit, &Network::passphrase_from_env()) +} + +/// Parse `"native"`, `"xlm"`, or `"CODE:ISSUER"` into an XDR asset plus the +/// bare code (used as the generated module name). +fn parse_asset(s: &str) -> Result<(xdr::Asset, String), String> { + if s == "native" || s == "xlm" { + return Ok((xdr::Asset::Native, s.to_string())); + } + let Some((code, issuer)) = s.split_once(':') else { + return Err(format!( + "invalid asset `{s}`: expected `native`, `xlm`, or `CODE:ISSUER`" + )); + }; + if code.is_empty() || code.len() > 12 || !code.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(format!( + "invalid asset code `{code}` in `{s}`: expected 1-12 ASCII letters or digits" + )); + } + let issuer: xdr::AccountId = issuer + .parse() + .map_err(|e| format!("invalid issuer account in `{s}`: {e}"))?; + let asset_code: xdr::AssetCode = code + .parse() + .map_err(|e| format!("invalid asset code `{code}` in `{s}`: {e}"))?; + Ok(( + match asset_code { + xdr::AssetCode::CreditAlphanum4(asset_code) => { + xdr::Asset::CreditAlphanum4(xdr::AlphaNum4 { asset_code, issuer }) + } + xdr::AssetCode::CreditAlphanum12(asset_code) => { + xdr::Asset::CreditAlphanum12(xdr::AlphaNum12 { asset_code, issuer }) + } + }, + code.to_string(), + )) +} + +/// The Stellar Asset Contract id for `asset` on `network`, derived offline +/// from the contract-id preimage — no network call needed. +fn generate_asset_id( + asset: &str, + network: &Network, +) -> Result<(stellar_strkey::Contract, String), String> { + let (asset, code) = parse_asset(asset)?; + let network_id = xdr::Hash(network.id()); + let preimage = xdr::HashIdPreimage::ContractId(xdr::HashIdPreimageContractId { + network_id, + contract_id_preimage: xdr::ContractIdPreimage::Asset(asset), + }); + let preimage_xdr = preimage + .to_xdr(xdr::Limits::none()) + .map_err(|e| format!("failed to encode the contract id preimage: {e}"))?; + Ok(( + stellar_strkey::Contract(Sha256::digest(preimage_xdr).into()), + code, + )) +} + +/// Generate a module (named after the asset code) exposing the asset's +/// contract id and token clients for the build-time network. +pub(crate) fn parse_literal(lit_str: &LitStr, network: &Network) -> syn::Result { + let err = |msg: String| syn::Error::new(lit_str.span(), msg); + let (contract_id, code) = generate_asset_id(&lit_str.value(), network).map_err(err)?; + let contract_id = format!("{contract_id}"); + let mod_name: syn::Ident = syn::parse_str(&code).map_err(|_| { + err(format!( + "cannot use asset code `{code}` as a Rust module name" + )) + })?; + Ok(quote! { + #[allow(non_snake_case)] + pub(crate) mod #mod_name { + use super::*; + /// Contract id for the Stellar Asset Contract + pub fn contract_id(env: &soroban_sdk::Env) -> soroban_sdk::Address { + soroban_sdk::Address::from_str(&env, #contract_id) + } + /// Create a Stellar Asset Client for the asset which provides an admin interface + pub fn stellar_asset_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::StellarAssetClient<'a> { + soroban_sdk::token::StellarAssetClient::new(&env, &contract_id(env)) + } + /// Create a Token Client for the asset which provides the standard token interface + pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) + } + } + }) +} + +#[cfg(test)] +mod test { + use super::*; + use Network::*; + const NETWORKS: [Network; 4] = [ + Network::Local, + Network::Testnet, + Network::Futurenet, + Network::Mainnet, + ]; + + const USDC: &str = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + fn expected_module(mod_name: &str, contract_id: &str) -> TokenStream { + let mod_name: syn::Ident = syn::parse_str(mod_name).unwrap(); + quote! { + #[allow(non_snake_case)] + pub(crate) mod #mod_name { + use super::*; + /// Contract id for the Stellar Asset Contract + pub fn contract_id(env: &soroban_sdk::Env) -> soroban_sdk::Address { + soroban_sdk::Address::from_str(&env, #contract_id) + } + /// Create a Stellar Asset Client for the asset which provides an admin interface + pub fn stellar_asset_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::StellarAssetClient<'a> { + soroban_sdk::token::StellarAssetClient::new(&env, &contract_id(env)) + } + /// Create a Token Client for the asset which provides the standard token interface + pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) + } + } + } + } + + // Test for parsing natve token + #[test] + fn parse_native() { + let (asset, code) = parse_asset("native").unwrap(); + assert_eq!(asset, xdr::Asset::Native); + assert_eq!(code, "native"); + let (asset, code) = parse_asset("xlm").unwrap(); + assert_eq!(asset, xdr::Asset::Native); + assert_eq!(code, "xlm"); + for network in &NETWORKS { + match ( + network, + generate_asset_id("native", network) + .unwrap() + .0 + .to_string() + .as_str(), + ) { + (Local, "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4") + | (Testnet, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC") + | (Futurenet, "CB64D3G7SM2RTH6JSGG34DDTFTQ5CFDKVDZJZSODMCX4NJ2HV2KN7OHT") + | (Mainnet, "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA") => {} + (x, s) => panic!("Unexpected network {x:?} with asset {s}"), + } + } + } + + // Test for parsing USDC token + #[test] + fn parse_usdc() { + for network in &NETWORKS { + let asset_id = generate_asset_id(USDC, network).unwrap().0; + match (network, asset_id.to_string().as_str()) { + (Local, "CB5SYISL2JCNQQRPFS5H4EFEESWUSNTDYMUNQX7TWZE45MYWYEYWCHAU") + | (Testnet, "CA2E53VHFZ6YSWQIEIPBXJQGT6VW3VKWWZO555XKRQXYJ63GEBJJGHY7") + | (Futurenet, "CBYZIQLTWJKSC34FJSCOGEQ63BR4YQWAKUDZDBMKIPUBBEMPRUMB5Z24") + | (Mainnet, "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75") => {} + (x, s) => panic!("Unexpected network {x:?} with asset {s}"), + } + } + } + + #[test] + fn native_client() { + let lit: syn::LitStr = syn::parse_quote!("native"); + let expected = expected_module( + "native", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + let generated = parse_literal(&lit, &Network::Testnet).unwrap(); + assert_eq!(generated.to_string(), expected.to_string()); + } + + #[test] + fn xlm_client() { + let lit: syn::LitStr = syn::parse_quote!("xlm"); + let expected = expected_module( + "xlm", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + let generated = parse_literal(&lit, &Network::Testnet).unwrap(); + assert_eq!(generated.to_string(), expected.to_string()); + } + + #[test] + fn usdc_client() { + let lit: syn::LitStr = + syn::parse_quote!("USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); + let expected = expected_module( + "USDC", + "CA2E53VHFZ6YSWQIEIPBXJQGT6VW3VKWWZO555XKRQXYJ63GEBJJGHY7", + ); + let generated = parse_literal(&lit, &Network::Testnet).unwrap(); + assert_eq!(generated.to_string(), expected.to_string()); + } + + #[test] + fn errors_are_compile_errors_not_panics() { + let cases = [ + "", // empty + "USDC", // no issuer + "USDC:not-a-key", // bad issuer + "toolongcodehere:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", // >12 chars + "US-DC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", // bad char + ]; + for case in cases { + let lit = syn::LitStr::new(case, proc_macro2::Span::call_site()); + assert!( + parse_literal(&lit, &Network::Testnet).is_err(), + "`{case}` should be rejected" + ); + } + } + + #[test] + fn digit_leading_code_is_a_module_name_error() { + // `1INCH` is a legal asset code but not a legal Rust module name. + let lit = syn::LitStr::new( + "1INCH:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + proc_macro2::Span::call_site(), + ); + let err = parse_literal(&lit, &Network::Testnet).unwrap_err(); + assert!(err.to_string().contains("module name"), "{err}"); + } +} diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs new file mode 100644 index 0000000..bef650d --- /dev/null +++ b/crates/stellar-registry-macro/src/contract.rs @@ -0,0 +1,457 @@ +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +use quote::quote; +use syn::{ + Expr, Ident, Token, + parse::{Parse, ParseStream}, +}; + +use stellar_registry_name::Prefixed; + +use crate::util::{Name, explorer_url, manifest, mod_ident, network_name}; + +/// `import_contract!(env_expr, name)` — `name` is a bare ident or a string +/// literal (optionally channel-prefixed, e.g. `"unverified/our_dao"`). +struct Input { + env: Expr, + name: Name, +} + +impl Parse for Input { + fn parse(input: ParseStream) -> syn::Result { + let env: Expr = input.parse()?; + input.parse::()?; + let name: Name = input.parse()?; + Ok(Self { env, name }) + } +} + +/// Validate a `C…` contract strkey; return it trimmed. +fn validate_contract_id(s: &str) -> Result { + let t = s.trim(); + t.parse::() + .map(|_| t.to_string()) + .map_err(|_| format!("not a valid contract id (C… strkey): {t:?}")) +} + +/// Cache file stem for a deployed-contract import. The channel is part of a +/// contract's identity, so it is part of the key — `unverified/foo` and `foo` +/// are different contracts and must never share cache files. +fn cache_stem(contract: &Prefixed) -> String { + match contract.channel() { + Some(channel) => format!("{channel}__{}", contract.mod_name()), + None => contract.mod_name(), + } +} + +/// `/deployed/.id` — cached deployed address. Namespaced +/// under `deployed/` so it can never collide with registry-downloaded or +/// workspace-built wasms, which live directly in the network dir under the +/// same `.wasm` naming. +fn cache_id_path(target_dir: &Path, contract: &Prefixed) -> PathBuf { + target_dir + .join("deployed") + .join(cache_stem(contract)) + .with_extension("id") +} + +/// `/deployed/.wasm` — the deployed contract's wasm, fetched +/// by address, that `contractimport!` reads to generate the client types. +fn cache_wasm_path(target_dir: &Path, contract: &Prefixed) -> PathBuf { + target_dir + .join("deployed") + .join(cache_stem(contract)) + .with_extension("wasm") +} + +/// The cached wasm belongs to a specific deployment. Online, if the freshly +/// resolved address differs from the previously cached id (redeploy under the +/// same name), the wasm must be refetched. Offline there is nothing to compare +/// against — the cache is trusted as-is. +fn wasm_is_stale(previously_cached_id: Option<&str>, address: &str, no_registry: bool) -> bool { + !no_registry && previously_cached_id.map(str::trim) != Some(address) +} + +/// The "things to try" footer for resolution failures: a name-check link when +/// the network has an explorer, a manual repro command, and the offline escape +/// hatch with the exact cache paths this build expects. +fn resolution_help(lookup: &Prefixed, id_path: &Path, wasm_path: &Path) -> String { + let name_check = explorer_url(&network_name()) + .map(|url| format!("- Check that you got the name right: {url}/contracts\n")) + .unwrap_or_default(); + format!( + "{name_check}\ + - Run `stellar registry fetch-contract-id {lookup}` yourself and make sure the name \ + and network match your expectations.\n\ + - Set STELLAR_NO_REGISTRY=1 to prevent network calls. You will need to create {id} and \ + {wasm} yourself, perhaps using `stellar registry fetch-contract-id` for the id and \ + `stellar contract fetch` for the wasm.", + id = id_path.display(), + wasm = wasm_path.display(), + ) +} + +/// Resolve the deployed address. Offline (`STELLAR_NO_REGISTRY=1`) the cached +/// `.id` is required. Online the cache is deliberately NOT consulted — +/// `fetch` runs every build (and fails if the contract is flagged), so a +/// contract flagged after the first build cannot slip through a stale `.id`. +/// IO is injected so the precedence is unit-testable offline. +fn resolve_address( + cache: Option, + no_registry: bool, + offline_help: &str, + fetch: impl FnOnce() -> Result, +) -> Result { + if no_registry { + return match cache { + Some(c) => validate_contract_id(&c), + None => Err(format!( + "STELLAR_NO_REGISTRY=1 but no cached contract id. Things to try:\n\n{offline_help}" + )), + }; + } + validate_contract_id(&fetch()?) +} + +/// Shell out to `stellar-registry-cli` to look up a deployed contract's id by +/// name. A current `stellar-registry-cli` refuses flagged contracts by default, +/// so a flagged contract fails this build; plugins that predate the check +/// resolve the id without it. Network selection is delegated to the CLI's own +/// config (`STELLAR_NETWORK`). Failures are mapped to the most specific message +/// the CLI's stderr allows. +fn fetch_contract_id(lookup: &Prefixed, help: &str) -> Result { + let out = Command::new("stellar") + .args(["registry", "fetch-contract-id"]) + .arg(lookup.to_string()) + .output() + .map_err(|e| { + format!( + "failed to run `stellar`: {e}. Install the Stellar CLI, then \ + `cargo install stellar-registry-cli` for the registry plugin." + ) + })?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()); + } + let stderr = String::from_utf8_lossy(&out.stderr); + // An installed-but-outdated plugin rejects the subcommand or an argument; + // check before the plugin-missing case, whose stderr wording overlaps. + if stderr.contains("unexpected argument") + || (stderr.contains("unrecognized subcommand") && stderr.contains("fetch-contract-id")) + { + return Err(format!( + "the installed `stellar registry` plugin is too old for import_contract!. Upgrade \ + it with `cargo install stellar-registry-cli --force`.\n\nstderr:\n{stderr}" + )); + } + if stderr.contains("unrecognized subcommand") || stderr.contains("no such command") { + return Err(format!( + "the `stellar registry` plugin is not installed. Install it with \ + `cargo install stellar-registry-cli`.\n\nstderr:\n{stderr}" + )); + } + if stderr.contains("flagged as compromised") { + return Err(format!( + "contract `{lookup}` is flagged as compromised in the registry; refusing to import it." + )); + } + Err(format!( + "Could not resolve a contract id for `{lookup}` on {network}. Things to try:\n\n\ + {help}\n\nstderr from `stellar registry fetch-contract-id`:\n{stderr}", + network = network_name(), + )) +} + +/// Shell out to `stellar contract fetch` to download a *deployed* contract's own +/// wasm by address (not a registry-published wasm-name) into `out_path`. +fn fetch_wasm(address: &str, out_path: &Path) -> Result<(), String> { + if let Some(parent) = out_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let out = Command::new("stellar") + .args(["contract", "fetch", "--id", address, "--out-file"]) + .arg(out_path) + .output() + .map_err(|e| { + format!("failed to run `stellar contract fetch`: {e}. Install the Stellar CLI and try again.") + })?; + if out.status.success() { + Ok(()) + } else { + Err(format!( + "`stellar contract fetch --id {address}` failed:\n{}", + String::from_utf8_lossy(&out.stderr) + )) + } +} + +/// Emit a block expression: generate the client types from the deployed +/// contract's own wasm, then construct the client bound to the baked address. +/// `use ::soroban_sdk;` resolves through the extern prelude — consistently +/// with the `::soroban_sdk::Env` binding below — so callers need no +/// `use soroban_sdk;` of their own. +fn expand( + env: &Expr, + mod_ident: &Ident, + wasm_path: &str, + address: &str, +) -> proc_macro2::TokenStream { + quote! { + { + #[allow(non_snake_case)] + mod #mod_ident { + #![allow(clippy::ref_option, clippy::too_many_arguments)] + use ::soroban_sdk; + soroban_sdk::contractimport!(file = #wasm_path); + } + let __env: &::soroban_sdk::Env = #env; + #mod_ident::Client::new( + __env, + &::soroban_sdk::Address::from_str(__env, #address), + ) + } + } +} + +pub(crate) fn import_contract( + input: proc_macro::TokenStream, +) -> syn::Result { + let Input { env, name } = syn::parse(input)?; + let span = name.span(); + let err = |msg: String| syn::Error::new(span, msg); + + // A deployed contract has no version; give the `@version` mistake its own + // message before `Prefixed` (which also rejects it) reports generically. + let raw = name.raw(); + if raw.contains('@') { + return Err(err(format!( + "import_contract! does not take a version — a deployed contract has no version \ + (got {raw:?}). Use just the contract name, e.g. `import_contract!(env, our_dao)`." + ))); + } + let contract: Prefixed = name.parse_as()?; + let mod_ident = mod_ident(&contract, span)?; + + let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); + let target_dir = stellar_build::get_target_dir(&manifest()?) + .map_err(|e| err(format!("could not determine the cargo target dir: {e}")))?; + let id_path = cache_id_path(&target_dir, &contract); + let wasm_path = cache_wasm_path(&target_dir, &contract); + let help = resolution_help(&contract, &id_path, &wasm_path); + + // 1. Resolve the deployed address (and, online, enforce the flag check). + let cached_id = std::fs::read_to_string(&id_path).ok(); + let address = resolve_address(cached_id.clone(), no_registry, &help, || { + let addr = validate_contract_id(&fetch_contract_id(&contract, &help)?)?; + if let Some(parent) = id_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&id_path, &addr); + Ok(addr) + }) + .map_err(err)?; + + // 2. Ensure the deployed contract's wasm is on disk for `contractimport!`, + // refetching if the name resolved to a different deployment than the + // cached wasm came from. + if !wasm_path.exists() || wasm_is_stale(cached_id.as_deref(), &address, no_registry) { + if no_registry { + return Err(err(format!( + "STELLAR_NO_REGISTRY=1 but no cached wasm at {path}. Build online once (which \ + fetches it), or run `stellar contract fetch --id {address} \ + --out-file {path}` yourself.", + path = wasm_path.display(), + ))); + } + fetch_wasm(&address, &wasm_path).map_err(err)?; + } + + // 3. Generate the client from that wasm and bind it to the address. + Ok(expand( + &env, + &mod_ident, + &wasm_path.to_string_lossy(), + &address, + )) +} + +#[cfg(test)] +mod helpers { + use super::*; + use std::path::Path; + + // A real, valid contract strkey (from soroban-sdk docs). + const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + + fn prefixed(s: &str) -> Prefixed { + s.parse().unwrap() + } + + #[test] + fn validate_contract_id_trims_and_checks() { + assert_eq!( + validate_contract_id(&format!(" {VALID}\n")).unwrap(), + VALID + ); + assert!(validate_contract_id("not-an-address").is_err()); + assert!(validate_contract_id("").is_err()); + } + + #[test] + fn cache_paths_are_namespaced_under_deployed() { + assert_eq!( + cache_id_path(Path::new("target"), &prefixed("our-dao")), + Path::new("target/deployed/our_dao.id") + ); + assert_eq!( + cache_wasm_path(Path::new("target"), &prefixed("our-dao")), + Path::new("target/deployed/our_dao.wasm") + ); + } + + #[test] + fn cache_paths_include_the_channel() { + // `unverified/foo` and `foo` are different contracts — different files. + assert_eq!( + cache_wasm_path(Path::new("target"), &prefixed("unverified/foo")), + Path::new("target/deployed/unverified__foo.wasm") + ); + assert_ne!( + cache_wasm_path(Path::new("target"), &prefixed("unverified/foo")), + cache_wasm_path(Path::new("target"), &prefixed("foo")), + ); + } + + #[test] + fn wasm_staleness_tracks_address_changes_online_only() { + const OTHER: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + // Online: no prior id, or a different prior id → stale. + assert!(wasm_is_stale(None, VALID, false)); + assert!(wasm_is_stale(Some(OTHER), VALID, false)); + // Online: same id (even with cache whitespace) → fresh. + assert!(!wasm_is_stale(Some(VALID), VALID, false)); + assert!(!wasm_is_stale(Some(&format!("{VALID}\n")), VALID, false)); + // Offline: nothing to compare against — trust the cache. + assert!(!wasm_is_stale(None, VALID, true)); + assert!(!wasm_is_stale(Some(OTHER), VALID, true)); + } + + #[test] + fn resolution_help_lists_repro_and_offline_paths() { + let lookup: Prefixed = "unverified/our-dao".parse().unwrap(); + let help = resolution_help( + &lookup, + Path::new("target/deployed/unverified__our_dao.id"), + Path::new("target/deployed/unverified__our_dao.wasm"), + ); + assert!(help.contains("stellar registry fetch-contract-id unverified/our-dao")); + assert!(help.contains("STELLAR_NO_REGISTRY=1")); + assert!(help.contains("target/deployed/unverified__our_dao.id")); + assert!(help.contains("target/deployed/unverified__our_dao.wasm")); + } +} + +#[cfg(test)] +mod resolution { + use super::*; + const A: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + const B: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + fn no_fetch() -> Result { + Err("fetch should not run".into()) + } + + #[test] + fn offline_uses_cache() { + let got = resolve_address(Some(B.to_string()), true, "help", no_fetch); + assert_eq!(got.unwrap(), B); + } + + #[test] + fn offline_errors_without_cache() { + let got = resolve_address(None, true, "try this instead", no_fetch); + let msg = got.unwrap_err(); + assert!(msg.contains("STELLAR_NO_REGISTRY"), "{msg}"); + assert!(msg.contains("try this instead"), "{msg}"); + } + + #[test] + fn online_fetches_and_ignores_stale_cache() { + // A cached id must NOT short-circuit the online fetch (+ flag check). + let got = resolve_address(Some(B.to_string()), false, "help", || Ok(A.to_string())); + assert_eq!(got.unwrap(), A); + } + + #[test] + fn online_validates_fetched_id() { + let got = resolve_address(None, false, "help", || Ok("garbage".to_string())); + assert!(got.unwrap_err().contains("not a valid contract id")); + } +} + +#[cfg(test)] +mod codegen { + use super::*; + use proc_macro2::Span; + use quote::quote; + use syn::{Ident, parse2}; + + const A: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + + #[test] + fn parses_env_and_string_name() { + let input: Input = parse2(quote!(env, "unverified/our_dao")).unwrap(); + assert_eq!(input.name.raw(), "unverified/our_dao"); + } + + #[test] + fn parses_env_and_ident_name() { + let input: Input = parse2(quote!(env, registry)).unwrap(); + assert_eq!(input.name.raw(), "registry"); + } + + #[test] + fn version_suffix_is_rejected_by_the_type() { + let input: Input = parse2(quote!(env, "our_dao@1.0.0")).unwrap(); + let err = input.name.parse_as::().unwrap_err(); + assert!(err.to_string().contains("version"), "{err}"); + } + + #[test] + fn expand_emits_contractimport_and_bound_client() { + let env: syn::Expr = parse2(quote!(env)).unwrap(); + let ident = Ident::new("our_dao", Span::call_site()); + let out = expand( + &env, + &ident, + "/tmp/target/stellar/local/deployed/our_dao.wasm", + A, + ) + .to_string(); + assert!( + out.contains("contractimport"), + "generates types from the wasm: {out}" + ); + assert!( + out.contains("our_dao.wasm"), + "references the fetched wasm: {out}" + ); + assert!( + out.contains("use :: soroban_sdk"), + "binds the sdk through the extern prelude, not the caller's scope: {out}" + ); + assert!( + out.contains("our_dao :: Client :: new"), + "constructs the client: {out}" + ); + assert!( + out.contains("Address :: from_str"), + "builds the address: {out}" + ); + assert!(out.contains(A), "bakes the resolved id: {out}"); + } +} diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs new file mode 100644 index 0000000..cf8e355 --- /dev/null +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -0,0 +1,257 @@ +use std::env; +use std::path::{Path, PathBuf}; + +use proc_macro2::Span; +use quote::quote; +use syn::{ + Ident, + parse::{Parse, ParseStream, Result}, +}; + +use stellar_registry_name::Versioned; + +use crate::util::{Name, explorer_url, manifest, mod_ident, network_name}; + +pub(crate) fn import_contract_client( + input: proc_macro::TokenStream, +) -> Result { + let WasmBinary { mod_name, file } = syn::parse::(input)?; + + Ok(quote! { + pub(crate) mod #mod_name { + #![allow(clippy::ref_option, clippy::too_many_arguments)] + use super::soroban_sdk; + soroban_sdk::contractimport!(file = #file); + } + }) +} + +struct WasmBinary { + pub mod_name: Ident, + pub file: String, +} + +impl Parse for WasmBinary { + fn parse(input: ParseStream) -> Result { + let name: Name = input.parse()?; + // A published wasm may carry an `@version` suffix, so this parses as + // `Versioned`; a malformed version is a compile error, never silently + // "latest". + let wasm: Versioned = name.parse_as()?; + let mod_name = mod_ident(wasm.name(), name.span())?; + let wasm_path = resolve_wasm_path(&wasm, &mod_name)?; + let file = wasm_path.display().to_string(); + Ok(Self { mod_name, file }) + } +} + +/// `[__][_]` — the channel is part of a published +/// wasm's identity, so `unverified/foo` never shares a file with `foo`. Bare +/// names stay bare so workspace-compiled contracts (written by stellar-build +/// as `.wasm`) are still found. +fn wasm_file_stem(channel: Option<&str>, mod_name: &Ident, version: Option<&str>) -> String { + let mut stem = match channel { + Some(channel) => format!("{channel}__{mod_name}"), + None => mod_name.to_string(), + }; + if let Some(v) = version { + stem = format!("{stem}_{}", v.replace('.', "_")); + } + stem +} + +fn build_local_wasm_path( + target_dir: &Path, + channel: Option<&str>, + mod_name: &Ident, + version: Option<&str>, +) -> PathBuf { + target_dir + .join(wasm_file_stem(channel, mod_name, version)) + .with_extension("wasm") +} + +fn resolve_wasm_path(wasm: &Versioned, mod_name: &Ident) -> Result { + let span = mod_name.span(); + let version = wasm.version().map(ToString::to_string); + let target_dir = stellar_build::get_target_dir(&manifest()?).map_err(|e| { + syn::Error::new( + span, + format!("could not determine the cargo target dir: {e}"), + ) + })?; + let local_path = build_local_wasm_path( + &target_dir, + wasm.name().channel(), + mod_name, + version.as_deref(), + ); + + // 1. Check local build target + if local_path.exists() { + return canonicalized(&local_path, span); + } + + // 2. If STELLAR_NO_REGISTRY set to 1, error + if env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1") { + return Err(syn::Error::new( + span, + format!( + "No local wasm found and STELLAR_NO_REGISTRY=1 so not checking Registry. \ + Download manually with `stellar registry download \"{wasm}\"`", + ), + )); + } + + // 3. if var absent or set to something else, try to download + download_from_registry(wasm, &local_path, span, version.as_deref()) +} + +fn canonicalized(path: &Path, span: Span) -> Result { + path.canonicalize().map_err(|e| { + syn::Error::new( + span, + format!("could not canonicalize {}: {e}", path.display()), + ) + }) +} + +fn download_from_registry( + wasm: &Versioned, + local_path: &Path, + span: Span, + version: Option<&str>, +) -> Result { + let lookup_name = wasm.name().to_string(); + + // 1. create `target/stellar/[network]` directory, if not already present + if let Some(parent) = local_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + syn::Error::new(span, format!("could not create {}: {e}", parent.display())) + })?; + } + + // 2. download using `stellar registry download` + let mut args = vec![ + "registry".to_string(), + "download".to_string(), + lookup_name.clone(), + "--out-file".to_string(), + local_path.display().to_string(), + ]; + if let Some(v) = version { + args.push("--version".to_string()); + args.push(v.to_string()); + } + let out = std::process::Command::new("stellar") + .args(&args) + .output() + .map_err(|e| { + syn::Error::new( + span, + format!( + "failed to run `stellar registry download`: {e}. Install the Stellar CLI, \ + then `cargo install stellar-registry-cli` for the registry plugin." + ), + ) + })?; + + // 3. check status, mapping failures to the most specific message the + // CLI's stderr allows (mirrors fetch_contract_id in contract.rs). + if out.status.success() && local_path.exists() { + return canonicalized(local_path, span); + } + let stderr = String::from_utf8_lossy(&out.stderr); + if stderr.contains("unexpected argument") + || (stderr.contains("unrecognized subcommand") && stderr.contains("download")) + { + return Err(syn::Error::new( + span, + format!( + "the installed `stellar registry` plugin is too old for \ + import_contract_client!. Upgrade it with \ + `cargo install stellar-registry-cli --force`.\n\nstderr:\n{stderr}" + ), + )); + } + if stderr.contains("unrecognized subcommand") || stderr.contains("no such command") { + return Err(syn::Error::new( + span, + format!( + "the `stellar registry` plugin is not installed. Install it with \ + `cargo install stellar-registry-cli`.\n\nstderr:\n{stderr}" + ), + )); + } + let network = network_name(); + let name_check = explorer_url(&network).map_or_else( + || "\n1. check the name & network and try again".to_string(), + |url| format!("\n1. check that you got the name right: {url}/wasms"), + ); + let local_path = local_path.display().to_string(); + Err(syn::Error::new( + span, + format!( + "Could not find Wasm `{lookup_name}` on {network}. Checked: \ + \n\n• {local_path} \ + \n• `stellar registry download {lookup_name}` \ + \n\nYou can: \ + {name_check} \ + \n2. add this Wasm to your local `target` directory manually \ + (perhaps by compiling a contract) \ + \n3. run `stellar registry download {lookup_name}` yourself. \ + \n\nSet STELLAR_NO_REGISTRY=1 to skip registry lookup.\ + \n\nstderr from `stellar registry download`:\n{stderr}" + ), + )) +} + +#[cfg(test)] +mod test_build_local_wasm_path { + use super::*; + use std::path::Path; + + fn ident(string: &str) -> Ident { + Ident::new(string, proc_macro2::Span::call_site()) + } + + #[test] + fn includes_underscore_delimited_version() { + let path = build_local_wasm_path(Path::new("target"), None, &ident("a"), Some("1.0.0")); + assert_eq!(path, Path::new("target/a_1_0_0.wasm")); + } + + #[test] + fn no_version() { + let path = build_local_wasm_path(Path::new("target"), None, &ident("registry"), None); + assert_eq!(path, Path::new("target/registry.wasm")); + } + + #[test] + fn prerelease_version() { + let path = + build_local_wasm_path(Path::new("target"), None, &ident("foo"), Some("1.0.0-rc.1")); + assert_eq!(path, Path::new("target/foo_1_0_0-rc_1.wasm")); + } + + #[test] + fn channel_is_part_of_the_stem() { + // `unverified/foo` and `foo` are different published wasms. + let channeled = + build_local_wasm_path(Path::new("target"), Some("unverified"), &ident("foo"), None); + assert_eq!(channeled, Path::new("target/unverified__foo.wasm")); + assert_ne!( + channeled, + build_local_wasm_path(Path::new("target"), None, &ident("foo"), None) + ); + assert_eq!( + build_local_wasm_path( + Path::new("target"), + Some("unverified"), + &ident("foo"), + Some("1.0.0") + ), + Path::new("target/unverified__foo_1_0_0.wasm") + ); + } +} diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs new file mode 100644 index 0000000..2a11f44 --- /dev/null +++ b/crates/stellar-registry-macro/src/lib.rs @@ -0,0 +1,96 @@ +//! Proc macros for the Stellar Registry: import deployed contracts +//! (`import_contract!`), published wasms (`import_contract_client!`), and +//! Stellar assets (`import_asset!`) as type-safe soroban clients, resolved at +//! build time. +extern crate proc_macro; +use proc_macro::TokenStream; + +mod asset; +mod contract; +mod contract_client; +mod util; + +use util::ProcMacroWrapper as _; + +/// Generate a type-safe client for a deployed, registry-named contract, +/// already bound to its on-chain address — collapsing "look up the address" +/// and "generate the client type" into one call. +/// +/// ```ignore +/// // `env: &Env` +/// let dao = stellar_registry::import_contract!(env, our_dao); +/// dao.create_proposal(/* ... */); +/// ``` +/// +/// The name is a bare ident or a string literal, optionally channel-prefixed +/// (`import_contract!(env, "unverified/our-dao")`). A deployed contract has no +/// version, so no `@version` suffix is accepted. With a string literal, the +/// generated module name is the contract name with `-` replaced by `_`. +/// +/// Resolved at build time: +/// - **address** — `stellar registry fetch-contract-id`, cached at +/// `target/stellar//deployed/.id` (channel-prefixed +/// names cache as `__.id`). The online lookup **fails +/// compilation if the contract is flagged as compromised** in the registry, +/// and a cached id is deliberately ignored while online so a contract flagged +/// after the first build cannot slip through a stale cache. +/// - **wasm** — the deployed contract's *own* wasm, via `stellar contract +/// fetch --id
`, cached beside the id. Client types are generated +/// from it, so a contract whose wasm was never published to the registry +/// still works. +/// +/// Set `STELLAR_NO_REGISTRY=1` to forbid the network calls; the cached id and +/// wasm are then required (build online once, or create them yourself with +/// `stellar registry fetch-contract-id` and `stellar contract fetch`). Because +/// a real on-chain address is baked in, if the named contract is redeployed, +/// delete the cached files (or `cargo clean`) and rebuild. +#[proc_macro] +pub fn import_contract(input: TokenStream) -> TokenStream { + contract::import_contract(input).to_token_stream() +} + +/// Generate a contract client from a published wasm — from your workspace's +/// `target` directory if present, otherwise downloaded from the registry. +/// +/// ```ignore +/// // Workspace wasms or registry names without hyphens: +/// import_contract_client!(registry); +/// +/// // Hyphenated or channel-prefixed registry names: +/// import_contract_client!("unverified/guess-the-number"); +/// +/// // A specific published version (leading `v` optional): +/// import_contract_client!("registry@1.0.0"); +/// ``` +/// +/// Unlike [`import_contract!`], this looks up a published **wasm** — which has +/// versions — and only generates the client types; it does not bind them to a +/// deployed address. With a string literal, the generated module name is the +/// contract name with `-` replaced by `_`. +/// +/// Set `STELLAR_NO_REGISTRY=1` to skip the registry download; the wasm must +/// then already exist at +/// `target/stellar//[__][_].wasm` +/// (perhaps put there by `stellar registry download`, or by compiling a +/// workspace contract). +#[proc_macro] +pub fn import_contract_client(input: TokenStream) -> TokenStream { + contract_client::import_contract_client(input).to_token_stream() +} + +/// Generate a module with the Stellar Asset Contract id and token clients for +/// an asset, computed offline for the build-time network (`STELLAR_NETWORK` / +/// `STELLAR_NETWORK_PASSPHRASE`, defaulting to local). +/// +/// ```ignore +/// import_asset!("native"); // or "xlm" +/// import_asset!("USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); +/// ``` +/// +/// The generated module — named after the asset code — exposes `contract_id`, +/// `token_client` (the standard token interface) and `stellar_asset_client` +/// (the asset admin interface). +#[proc_macro] +pub fn import_asset(input: TokenStream) -> TokenStream { + asset::import_asset(input).to_token_stream() +} diff --git a/crates/stellar-registry-macro/src/util.rs b/crates/stellar-registry-macro/src/util.rs new file mode 100644 index 0000000..dda04e0 --- /dev/null +++ b/crates/stellar-registry-macro/src/util.rs @@ -0,0 +1,197 @@ +use std::path::PathBuf; + +use proc_macro2::Span; +use stellar_registry_name as name; +use syn::{ + Ident, LitStr, + parse::{Parse, ParseStream}, +}; + +/// Path to the compiling crate's `Cargo.toml`. +pub(crate) fn manifest() -> syn::Result { + let dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| { + syn::Error::new( + Span::call_site(), + "CARGO_MANIFEST_DIR is not set; are you compiling with cargo?", + ) + })?; + Ok(PathBuf::from(dir).join("Cargo.toml")) +} + +/// A contract or wasm name argument: a bare ident (`registry`) or a string literal +/// (`"unverified/guess-the-number@1.0.0"`). +pub(crate) enum Name { + Ident(Ident), + LitStr(LitStr), +} + +impl Parse for Name { + fn parse(input: ParseStream) -> syn::Result { + if input.peek(LitStr) { + Ok(Self::LitStr(input.parse()?)) + } else { + Ok(Self::Ident(input.parse()?)) + } + } +} + +impl Name { + pub(crate) fn span(&self) -> Span { + match self { + Self::Ident(ident) => ident.span(), + Self::LitStr(lit) => lit.span(), + } + } + + pub(crate) fn raw(&self) -> String { + match self { + Self::Ident(ident) => ident.to_string(), + Self::LitStr(lit) => lit.value(), + } + } + + /// Parse into a typed registry name ([`name::Prefixed`] / + /// [`name::Versioned`]), reporting failures at this argument's span. + pub(crate) fn parse_as(&self) -> syn::Result + where + T: std::str::FromStr, + { + self.raw() + .parse() + .map_err(|e| syn::Error::new(self.span(), e)) + } +} + +/// Rust module `Ident` for a parsed name (`-` → `_`), or a compile error at +/// `span` if the result is not a valid identifier (e.g. starts with a digit, +/// or is a Rust keyword). +pub(crate) fn mod_ident(name: &name::Prefixed, span: Span) -> syn::Result { + let mod_name = name.mod_name(); + syn::parse_str::(&mod_name) + .map(|mut ident| { + ident.set_span(span); + ident + }) + .map_err(|_| { + syn::Error::new( + span, + format!( + "cannot derive a Rust module name from `{name}`: `{mod_name}` is not a valid identifier" + ), + ) + }) +} + +/// `STELLAR_NETWORK` identifier (defaulting to `local`) — the same value +/// `stellar_build::get_target_dir` uses for the network segment of cache paths. +pub(crate) fn network_name() -> String { + std::env::var("STELLAR_NETWORK").unwrap_or_else(|_| "local".to_owned()) +} + +/// The registry explorer for the network, if one exists. +pub(crate) fn explorer_url(network: &str) -> Option<&'static str> { + match network { + "testnet" => Some("https://testnet.rgstry.xyz"), + "mainnet" => Some("https://stellar.rgstry.xyz"), + _ => None, + } +} + +/// Bridge a fallible macro implementation to the `proc_macro` entry point: +/// `Err` becomes a `compile_error!` at the error's span. +pub(crate) trait ProcMacroWrapper { + fn to_token_stream(self) -> proc_macro::TokenStream; +} + +impl ProcMacroWrapper for syn::Result { + fn to_token_stream(self) -> proc_macro::TokenStream { + self.map_or_else(|e| e.to_compile_error().into(), Into::into) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + use stellar_registry_name::{Prefixed, Versioned}; + + fn name(tokens: proc_macro2::TokenStream) -> Name { + syn::parse2(tokens).unwrap() + } + + #[test] + fn ident_name() { + assert_eq!(name(quote!(registry)).raw(), "registry"); + } + + #[test] + fn litstr_name() { + assert_eq!( + name(quote!("unverified/guess-the-number")).raw(), + "unverified/guess-the-number" + ); + } + + #[test] + fn parse_as_prefixed_rejects_version() { + let err = name(quote!("our_dao@1.0.0")) + .parse_as::() + .unwrap_err(); + assert!(err.to_string().contains("version"), "{err}"); + } + + #[test] + fn parse_as_versioned_accepts_version() { + let wasm: Versioned = name(quote!("registry@v1.0.0")).parse_as().unwrap(); + assert_eq!(wasm.version().unwrap().to_string(), "1.0.0"); + } + + #[test] + fn parse_as_reports_bad_versions_instead_of_dropping_them() { + let err = name(quote!("registry@garbage")) + .parse_as::() + .unwrap_err(); + assert!(err.to_string().contains("invalid version"), "{err}"); + } + + #[test] + fn parse_as_rejects_empty_string() { + let err = name(quote!("")).parse_as::().unwrap_err(); + assert!(err.to_string().contains("empty"), "{err}"); + } + + #[test] + fn mod_ident_derives_underscored_module() { + let p: Prefixed = "unverified/guess-the-number".parse().unwrap(); + let ident = mod_ident(&p, Span::call_site()).unwrap(); + assert_eq!(ident.to_string(), "guess_the_number"); + } + + #[test] + fn mod_ident_lowercases() { + let p: Prefixed = "Oh-No-How-Even".parse().unwrap(); + let ident = mod_ident(&p, Span::call_site()).unwrap(); + assert_eq!(ident.to_string(), "oh_no_how_even"); + } + + #[test] + fn mod_ident_errors_instead_of_panicking_on_digit_start() { + let p: Prefixed = "123bad".parse().unwrap(); + let err = mod_ident(&p, Span::call_site()).unwrap_err(); + assert!(err.to_string().contains("not a valid identifier"), "{err}"); + } + + #[test] + fn mod_ident_errors_on_keywords() { + let p: Prefixed = "mod".parse().unwrap(); + assert!(mod_ident(&p, Span::call_site()).is_err()); + } + + #[test] + fn explorer_urls() { + assert_eq!(explorer_url("testnet"), Some("https://testnet.rgstry.xyz")); + assert_eq!(explorer_url("mainnet"), Some("https://stellar.rgstry.xyz")); + assert_eq!(explorer_url("local"), None); + assert_eq!(explorer_url("futurenet"), None); + } +} diff --git a/crates/stellar-registry-name/Cargo.toml b/crates/stellar-registry-name/Cargo.toml new file mode 100644 index 0000000..722b467 --- /dev/null +++ b/crates/stellar-registry-name/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stellar-registry-name" +version = "0.0.1" +edition = "2024" +description = "Standard name parsing and formatting for Stellar Registry" +license = "Apache-2.0" +repository = "https://github.com/stellar-registry/cli/tree/main/crates/stellar-registry-name" + +[lib] +crate-type = ["rlib"] + +[dependencies] +thiserror = { workspace = true } +semver = { workspace = true } + +[dev-dependencies] +expect-test = "1.5" diff --git a/crates/stellar-registry-name/README.md b/crates/stellar-registry-name/README.md new file mode 100644 index 0000000..9fa4284 --- /dev/null +++ b/crates/stellar-registry-name/README.md @@ -0,0 +1,18 @@ +# stellar-registry-name + +_Parse, don't validate_ + +This library defines the standard names allowed throughout the Stellar Registry system. Parsing is the only way to construct them: + +- **`Prefixed`** — `name` or `channel/name`. Rejects empty names, `@` (deployed contracts have no version), multiple slashes, and invalid characters. Private fields with accessors: `name()` / `channel()` / `mod_name()` / `canonical_name()`. +- **`Versioned`** — `Prefixed` + optional `@version` (leading `v` tolerated). A malformed version is an **error**, never silently "latest". + +## Use in contract macros + +This library backs the proc-macros shipped in the [stellar-registry](https://crates.io/crates/stellar-registry) crate. (This library is slim enough to be appropriate for use in Stellar smart contracts.) + +`import_contract!` parses `Prefixed`, `import_contract_client!` parses `Versioned` — so "contracts have no version" is enforced by the type, not a string check. + +## Use in CLI + +This same library backs [stellar-registry-cli](https://crates.io/crates/stellar-registry-cli)'s argument parsing, so bad names fail at parsing time with real messages. diff --git a/crates/stellar-registry-name/src/common.rs b/crates/stellar-registry-name/src/common.rs new file mode 100644 index 0000000..428212f --- /dev/null +++ b/crates/stellar-registry-name/src/common.rs @@ -0,0 +1,17 @@ +/// Canonical on-chain form of a registry name: lowercase with `_` → `-`. +/// The registry contract stores names in this form. +#[must_use] +pub fn canonicalize(name: &str) -> String { + name.replace('_', "-").to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::canonicalize; + + #[test] + fn canonicalize_lowercases_and_hyphenates() { + assert_eq!(canonicalize("Guess_The_Number"), "guess-the-number"); + assert_eq!(canonicalize("registry"), "registry"); + } +} diff --git a/crates/stellar-registry-name/src/error.rs b/crates/stellar-registry-name/src/error.rs new file mode 100644 index 0000000..6dc0030 --- /dev/null +++ b/crates/stellar-registry-name/src/error.rs @@ -0,0 +1,23 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("registry name cannot be empty")] + Empty, + #[error("registry name `{0}` cannot start or end with `/`")] + LeadingOrTrailingSlash(String), + #[error("registry name `{0}` has more than one `/`; expected `name` or `channel/name`")] + TooManySlashes(String), + #[error( + "unexpected `@` in `{0}`: a version is not allowed in this name (wasm versions are passed separately, e.g. `--version 1.0.0`; deployed contracts have no version)" + )] + UnexpectedVersion(String), + #[error( + "invalid character `{1}` in registry name `{0}`; expected ASCII letters, digits, `-` or `_`" + )] + InvalidCharacter(String, char), + #[error("invalid version `{version}` in `{input}`: {source}")] + InvalidVersion { + input: String, + version: String, + source: semver::Error, + }, +} diff --git a/crates/stellar-registry-name/src/lib.rs b/crates/stellar-registry-name/src/lib.rs new file mode 100644 index 0000000..26ce779 --- /dev/null +++ b/crates/stellar-registry-name/src/lib.rs @@ -0,0 +1,15 @@ +//! Registry name types +//! +//! - [`Prefixed`] — `name` or `channel/name`, no version. +//! - [`Versioned`] — a [`Prefixed`] plus an optional `@version` suffix. + +mod common; +pub mod error; +pub mod prefixed; +pub mod versioned; + +pub use prefixed::Prefixed; +pub use versioned::Versioned; + +pub use common::canonicalize; +pub use error::Error; diff --git a/crates/stellar-registry-name/src/prefixed.rs b/crates/stellar-registry-name/src/prefixed.rs new file mode 100644 index 0000000..09b3a1b --- /dev/null +++ b/crates/stellar-registry-name/src/prefixed.rs @@ -0,0 +1,168 @@ +use std::{fmt::Display, str::FromStr}; + +use super::Error; + +/// A registry contract name with an optional channel prefix, e.g. `our-dao` +/// or `unverified/our-dao`. +/// +/// Only constructible by parsing, which enforces: non-empty, at most one `/` +/// (splitting `channel/name`), no `@` (deployed contracts have no version), +/// and every segment made of ASCII letters, digits, `-` or `_`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Prefixed { + channel: Option, + name: String, +} + +impl FromStr for Prefixed { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(Error::Empty); + } + if s.contains('@') { + return Err(Error::UnexpectedVersion(s.to_owned())); + } + if s.starts_with('/') || s.ends_with('/') { + return Err(Error::LeadingOrTrailingSlash(s.to_owned())); + } + let mut segments = s.split('/'); + let (channel, name) = match (segments.next(), segments.next(), segments.next()) { + (Some(name), None, _) => (None, name), + (Some(channel), Some(name), None) => (Some(channel), name), + _ => return Err(Error::TooManySlashes(s.to_owned())), + }; + for segment in channel.iter().chain(std::iter::once(&name)) { + if let Some(c) = segment + .chars() + .find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_') + { + return Err(Error::InvalidCharacter(s.to_owned(), c)); + } + } + Ok(Self { + channel: channel.map(str::to_owned), + name: name.to_owned(), + }) + } +} + +impl Prefixed { + /// The bare contract name, without the channel prefix. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// The channel prefix, if any (`unverified` in `unverified/our-dao`). + #[must_use] + pub fn channel(&self) -> Option<&str> { + self.channel.as_deref() + } + + /// Rust module identifier derived from the name: `-` → `_` and chars to lowercase. + #[must_use] + pub fn mod_name(&self) -> String { + self.name.replace('-', "_").to_ascii_lowercase() + } + + /// Canonical on-chain form of the bare name (see [`super::canonicalize`]). + #[must_use] + pub fn canonical_name(&self) -> String { + super::canonicalize(&self.name) + } +} + +impl Display for Prefixed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Prefixed { channel, name } = &self; + write!( + f, + "{}{name}", + channel + .as_ref() + .map(|channel| format!("{channel}/")) + .unwrap_or_default() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_name() { + let p: Prefixed = "registry".parse().unwrap(); + assert_eq!(p.name(), "registry"); + assert_eq!(p.channel(), None); + assert_eq!(p.to_string(), "registry"); + } + + #[test] + fn channel_prefixed_hyphenated() { + let p: Prefixed = "unverified/guess-the-number".parse().unwrap(); + assert_eq!(p.channel(), Some("unverified")); + assert_eq!(p.name(), "guess-the-number"); + assert_eq!(p.mod_name(), "guess_the_number"); + assert_eq!(p.to_string(), "unverified/guess-the-number"); + } + + #[test] + fn underscored_name() { + let p: Prefixed = "my_contract".parse().unwrap(); + assert_eq!(p.name(), "my_contract"); + assert_eq!(p.mod_name(), "my_contract"); + assert_eq!(p.canonical_name(), "my-contract"); + } + + #[test] + fn rejects_empty() { + assert!(matches!("".parse::().unwrap_err(), Error::Empty)); + } + + #[test] + fn rejects_leading_and_trailing_slash() { + assert!(matches!( + "/guess-the-number".parse::().unwrap_err(), + Error::LeadingOrTrailingSlash(_) + )); + assert!(matches!( + "unverified/".parse::().unwrap_err(), + Error::LeadingOrTrailingSlash(_) + )); + } + + #[test] + fn rejects_multiple_slashes() { + assert!(matches!( + "a/b/c".parse::().unwrap_err(), + Error::TooManySlashes(_) + )); + assert!(matches!( + "a//b".parse::().unwrap_err(), + Error::TooManySlashes(_) + )); + } + + #[test] + fn rejects_version_suffix() { + assert!(matches!( + "our_dao@1.0.0".parse::().unwrap_err(), + Error::UnexpectedVersion(_) + )); + } + + #[test] + fn rejects_invalid_characters() { + assert!(matches!( + "hello world".parse::().unwrap_err(), + Error::InvalidCharacter(_, ' ') + )); + assert!(matches!( + "name!".parse::().unwrap_err(), + Error::InvalidCharacter(_, '!') + )); + } +} diff --git a/crates/stellar-registry-name/src/versioned.rs b/crates/stellar-registry-name/src/versioned.rs new file mode 100644 index 0000000..4c72dc4 --- /dev/null +++ b/crates/stellar-registry-name/src/versioned.rs @@ -0,0 +1,137 @@ +use std::{fmt::Display, str::FromStr}; + +use super::{Error, prefixed::Prefixed}; + +/// A [`Prefixed`] wasm name plus an optional `@version` suffix, e.g. +/// `registry@1.0.0` or `unverified/guess-the-number@v0.4.0` (leading `v` +/// tolerated). Only published wasms have versions; without a suffix the +/// registry serves the latest published version. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Versioned { + name: Prefixed, + version: Option, +} + +impl FromStr for Versioned { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s.split_once('@') { + Some((name, version_raw)) => { + let version = version_raw + .strip_prefix('v') + .unwrap_or(version_raw) + .parse() + .map_err(|source| Error::InvalidVersion { + input: s.to_owned(), + version: version_raw.to_owned(), + source, + })?; + Ok(Self { + name: name.parse()?, + version: Some(version), + }) + } + None => Ok(Self { + name: s.parse()?, + version: None, + }), + } + } +} + +impl Versioned { + /// The channel-prefixed name, without the version. + #[must_use] + pub fn name(&self) -> &Prefixed { + &self.name + } + + /// The requested version, if one was given. + #[must_use] + pub fn version(&self) -> Option<&semver::Version> { + self.version.as_ref() + } +} + +impl Display for Versioned { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Versioned { name, version } = &self; + + write!( + f, + "{name}{}", + version + .as_ref() + .map(|v| format!("@{v}")) + .unwrap_or_default() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_version() { + let v: Versioned = "registry".parse().unwrap(); + assert_eq!(v.name().name(), "registry"); + assert_eq!(v.version(), None); + assert_eq!(v.to_string(), "registry"); + } + + #[test] + fn with_version() { + let v: Versioned = "registry@1.0.1".parse().unwrap(); + assert_eq!(v.name().name(), "registry"); + assert_eq!(v.version().unwrap().to_string(), "1.0.1"); + assert_eq!(v.to_string(), "registry@1.0.1"); + } + + #[test] + fn strips_leading_v() { + let v: Versioned = "registry@v1.0.1".parse().unwrap(); + assert_eq!(v.version().unwrap().to_string(), "1.0.1"); + } + + #[test] + fn channel_prefixed_with_version() { + let v: Versioned = "unverified/guess-the-number@0.4.0".parse().unwrap(); + assert_eq!(v.name().channel(), Some("unverified")); + assert_eq!(v.name().name(), "guess-the-number"); + assert_eq!(v.name().mod_name(), "guess_the_number"); + assert_eq!(v.version().unwrap().to_string(), "0.4.0"); + } + + #[test] + fn prerelease_version() { + let v: Versioned = "registry@1.0.0-rc.1".parse().unwrap(); + assert_eq!(v.version().unwrap().to_string(), "1.0.0-rc.1"); + } + + #[test] + fn rejects_invalid_version_instead_of_dropping_it() { + // A bad version must be an error, not silently "no version requested". + assert!(matches!( + "foo@garbage".parse::().unwrap_err(), + Error::InvalidVersion { .. } + )); + assert!(matches!( + "foo@".parse::().unwrap_err(), + Error::InvalidVersion { .. } + )); + assert!(matches!( + "a@1.0.0@2.0.0".parse::().unwrap_err(), + Error::InvalidVersion { .. } + )); + } + + #[test] + fn rejects_bad_name_with_version() { + assert!(matches!( + "a/b/c@1.0.0".parse::().unwrap_err(), + Error::TooManySlashes(_) + )); + } +} diff --git a/crates/stellar-registry/Cargo.toml b/crates/stellar-registry/Cargo.toml index b013e54..af5fb0e 100644 --- a/crates/stellar-registry/Cargo.toml +++ b/crates/stellar-registry/Cargo.toml @@ -13,4 +13,4 @@ crate-type = ["rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -stellar-scaffold-macro = { workspace = true } +stellar-registry-macro = { workspace = true } diff --git a/crates/stellar-registry/README.md b/crates/stellar-registry/README.md index 7f92e32..2e6030e 100644 --- a/crates/stellar-registry/README.md +++ b/crates/stellar-registry/README.md @@ -2,34 +2,39 @@ Stellar cross-contract calls simplified. -Say you've got: +# Import contract with `import_contract!` -1. a contract deployed on Stellar's testnet or mainnet -2. a registered name for this contract in Stellar Registry (example: the `unverified` registry on testnet, which is registered in the official (verified) registry [with the name `unverified`](https://testnet.rgstry.xyz/contracts/unverified)) -3. a Wasm hash that is also in Stellar Registry (example: the [`registry`](https://testnet.rgstry.xyz/wasms/registry) Wasm used by the `unverified` contract above) +Import a contract (https://stellar.rgstry.xyz/contracts) directly, with a fully-typed interface ready to make cross-contract calls. -For now, the `stellar_registry` crate exports one macro: `import_contract_client!` +```rs +pub fn your_fn(env: &Env) { + let unverified_registry = stellar_registry::import_contract!(env, "unverified"); + unverified_registry.fetch_contract_id("guess-the-number"); +} +``` -This macro takes the name of the _Wasm_ binary from Stellar Registry: +# Import wasm with `import_contract_client!` + +Import a wasm (https://stellar.rgstry.xyz/wasms), which defines only behavior. You can optionally include a version, otherwise it fetches the latest. You need to instantiate with a contract ID. ```rs use soroban_sdk; // needs to be in-scope -stellar_registry::import_contract_client!(registry); +stellar_registry::import_contract_client!(unverified); ``` -This creates a `registry` module, equivalent to running: +This creates a `unverified` module, equivalent to running: ```bash -stellar registry download registry --out-file target/stellar/registry.wasm +stellar registry download unverified --out-file target/stellar/unverified.wasm ``` ...and then importing the Wasm with `soroban_sdk` like: ```rust -mod registry { +mod unverified { use super::soroban_sdk; - soroban_sdk::contractimport!(file = "target/stellar/registry.wasm"); + soroban_sdk::contractimport!(file = "target/stellar/unverified.wasm"); } ``` @@ -37,17 +42,28 @@ Within a method, you can now instantiate the client as usual, using the contract ```rust pub fn __constructor(env: &Env, admin: Address) { - let registry_client = registry::Client::new( + let unverified_client = registry::Client::new( env, &Address::from_str( env, "CAMLHKQHNZO2IOIBFUF5BGZ2V62BMS5QCWFFGRCB4NOB3G5OMDA7SGZN", ), ); - let = registry_client.fetch_contract_id(&String::from_str(env, &"world")); + let = unverified_client.fetch_contract_id(&String::from_str(env, &"world")); } ``` +# Import an asset with `import_asset!` + +Generate a module with the Stellar Asset Contract id and token clients for an asset, computed offline for the build-time network (`STELLAR_NETWORK` / `STELLAR_NETWORK_PASSPHRASE`, defaulting to local). + +```ignore +import_asset!("native"); // or "xlm" +import_asset!("USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); +``` + +The generated module — named after the asset code — exposes `contract_id`, `token_client` (the standard token interface) and `stellar_asset_client` (the asset admin interface). + # If you don't want your macro making network calls First, you should know that this macro doesn't make a network call _first_. It starts by looking in the current Cargo project's `target` directory for a `.wasm` file with the given name. Only if it fails to find one will it run `stellar registry download` to download the Wasm before importing it. @@ -68,8 +84,4 @@ If you need a specific (historic) version: import_contract_client!("registry@v1.0.0"); ``` -# Future - -Eventually, this crate will also export an `import_contract!` macro which will allow importing the _contract_ by name, rather than only the _Wasm_ by name. This will simplify the client creation logic shown above. - -Follow progress at https://github.com/stellar-registry/cli/issues +See [docs.rs/stellar-registry](https://docs.rs/stellar-registry/) for more details. diff --git a/crates/stellar-registry/src/lib.rs b/crates/stellar-registry/src/lib.rs index 471dc5a..7d820c0 100644 --- a/crates/stellar-registry/src/lib.rs +++ b/crates/stellar-registry/src/lib.rs @@ -2,4 +2,4 @@ //! `stellar-registry` is a collection of tools to help integrate with //! existing smart contracts on Stellar. //! -pub use stellar_scaffold_macro::*; +pub use stellar_registry_macro::{import_asset, import_contract, import_contract_client};