From 252c42ffa874b2cb1ff0247949d6abac5845d6aa Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:12:08 -0400 Subject: [PATCH 01/31] docs: design spec for import_contract! macro (#419) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- ...2026-07-02-import-contract-macro-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-import-contract-macro-design.md diff --git a/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md b/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md new file mode 100644 index 0000000..805d58e --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md @@ -0,0 +1,188 @@ +# `import_contract!` macro — Design + +**Issue:** stellar-scaffold/cli#419 — `` `import_contract!` macro `` +**Repo:** `stellar-registry/cli` +**Date:** 2026-07-02 + +## Goal + +Make cross-contract calls to a *named* Stellar Registry contract a one-liner: + +```rust +pub fn thing_doer(env: &Env) { + let dao = stellar_registry::import_contract!(env, our_dao); + dao.create_proposal(/* ... */); +} +``` + +`import_contract!` returns a ready-to-call, type-safe `Client` **already bound to the +deployed contract's on-chain address** — collapsing today's two steps into one. + +## Motivation + +Today a consumer writes two things (see `contracts/registry-tansu-manager/src/lib.rs`): + +```rust +stellar_registry::import_contract_client!(tansu_stub); // 1. generate the type +// ... +let c = tansu_stub::Client::new(env, &tansu); // 2. supply the Address by hand +``` + +`import_contract_client!` resolves the **wasm** by name (for the generated `Client` +type) but knows nothing about **where the contract is deployed**. The caller must +obtain the `Address` separately. `import_contract!` adds the missing half: resolve the +deployed address by name from the registry and bake it in. + +## Decisions (locked during brainstorming) + +1. **Address model — build-time bake.** Resolve name → address at *compile time* via + RPC and embed the address as a constant. Matches the issue's wording ("when you + build your contract … the macro would need to make network calls to look up the + contract"), mirrors how `import_contract_client!` downloads the wasm at build time, + and has zero runtime cost. Trade-off: the address is frozen at build; if the named + contract is redeployed, rebuild. +2. **Home — new local crate `stellar-registry-macro`** in this repo (not the external + `stellar-scaffold-macro`). Keeps registry-specific logic (`fetch-contract-id`) in + the registry's own repo and keeps the work executable here. +3. **Offline resolution — mirror `import_contract_client!`, plus an env override + checked first.** Consistency with the existing macro is the dominant value; the env + override makes CI / unit-test builds hermetic without a lockfile's tooling weight. + +## Non-goals (YAGNI) + +- Committed address lockfile (`registry-ids.toml`) with a `refresh` command. The `.id` + cache introduced here is a deliberate precursor if this is wanted later. +- Runtime on-chain address lookup (bake registry address, resolve target per call). +- Multiple addresses / address lists per import. +- Any change to `import_contract_client!` behavior. + +## Architecture + +### A. Crate layout + +New proc-macro crate: `crates/stellar-registry-macro`. + +- `Cargo.toml`: `[lib] proc-macro = true`; deps `syn`, `quote`, `proc-macro2`, + `stellar-build` (workspace). `syn`/`quote`/`proc-macro2` are added to + `[workspace.dependencies]` in the root `cli/Cargo.toml`. +- Root `cli/Cargo.toml` `[workspace.dependencies]` gains + `stellar-registry-macro = { path = "crates/stellar-registry-macro" }`. + (`members = ["crates/*"]` already auto-includes the new crate.) +- `crates/stellar-registry/Cargo.toml` adds `stellar-registry-macro = { workspace = true }`. +- `crates/stellar-registry/src/lib.rs` adds, beside the existing + `pub use stellar_scaffold_macro::*;`: + + ```rust + pub use stellar_registry_macro::import_contract; + ``` + +Consumers keep writing `stellar_registry::import_contract!(...)`. + +### B. Macro surface + +`import_contract!($env:expr, $name)`. + +- `$name` uses the **same grammar** as `import_contract_client!`: bare ident + (`registry`), string literal (`"unverified/our_dao"`), optional `@version` + (`"our_dao@v1.0.0"`), optional channel prefix. The module name is derived + identically: take the final `/`-segment, replace `-` with `_`. +- `$env` is an expression bound as `&Env`. **The caller passes `&env`** (an `&Env`); + documented in the macro docs. The expansion binds it once: + `let __env: &soroban_sdk::Env = $env;`. +- The macro expands to a **block expression** whose value is the constructed + `mod_name::Client`. + +### C. Codegen + +Primary approach — **delegate wasm/type generation to `import_contract_client!`** so no +resolution logic is duplicated: + +```rust +{ + stellar_registry::import_contract_client!(/* original $name tokens, verbatim */); + let __env: &soroban_sdk::Env = /* $env */ env; + our_dao::Client::new( + __env, + &soroban_sdk::Address::from_str(__env, "CABC…"), // baked, resolved at build + ) +} +``` + +- `import_contract_client!` emits `pub(crate) mod our_dao { use super::soroban_sdk; + soroban_sdk::contractimport!(file = "…our_dao.wasm"); }`. Inside the block its + `use super::soroban_sdk` resolves to the consumer's module — the **same** in-scope + `soroban_sdk` requirement the existing macro already imposes. +- `soroban_sdk::Address::from_str(env: &Env, strkey: &str) -> Address` is a real SDK + convenience (verified in soroban-sdk 26.0.0-rc.1 `src/address.rs`, wrapping + `from_string(&String::from_str(env, strkey))`; assumed stable in 27.0.0-rc.1 — verify + at build). +- The macro must recompute `mod_name` (last segment, `-`→`_`) to name the `Client` + path; it reuses the same derivation function `import_contract_client!` uses. + +**Fallback** if nesting a function-like proc-macro call inside generated output proves +fragile: inline the module ourselves — replicate the wasm-path resolution +(`resolve_wasm_path`) and emit `mod our_dao { … contractimport! … }` directly, then the +`Client::new` expression. Same output shape, no cross-macro dependency. + +### D. Address resolution (build time) + +Resolution order, first match wins: + +1. **Env override** — read `STELLAR_CONTRACT_ID_` (uppercased + `mod_name`, non-alphanumerics → `_`). If set, validate as a `C…` strkey and bake it. + Purpose: hermetic tests / CI with no files and no network. +2. **Cache file** — `target/stellar//.id`, a sibling of the wasm's + `.wasm`, where `` matches the wasm stem (`mod_name`, or + `mod_name_` when a version is given). Read, validate, + bake. Target dir + network come from `stellar_build::get_target_dir` / the network + env, exactly as `import_contract_client!` resolves the wasm path. +3. **`STELLAR_NO_REGISTRY=1`** — if set, emit `compile_error!` instead of any network + call (same escape hatch as the existing macro). +4. **RPC shell-out** — run `stellar registry fetch-contract-id `, capture + stdout (the `C…` address), validate the strkey, **write the `.id` cache file**, bake. + Network selection is delegated to the `stellar` CLI's own config/`STELLAR_NETWORK` + (the existing `download` shell-out passes no explicit network flag either). + +`` is the full name *including* any channel prefix and preserving hyphens +(e.g. `unverified/guess-the-number`) — `fetch-contract-id` takes a `PrefixedName` +positional and does no `_`/`-` normalization. + +### E. Error handling + +All failures are `compile_error!` at the macro call site: + +- **Invalid / empty strkey** (from any source) → error naming the contract and the + source (env var / cache file / CLI output). +- **CLI missing or fetch failed** → error mirroring `import_contract_client!`'s download + copy: check the name & network; try `stellar registry fetch-contract-id ` + yourself; set `STELLAR_NO_REGISTRY=1` to skip the registry lookup. + +### F. Testing + +- **Pure-helper unit tests** (mirror scaffold-macro's `parse_name_and_version` test + module): `mod_name` derivation, `STELLAR_CONTRACT_ID_*` env-var-name sanitization, + strkey validation, `.id` cache-path construction (with/without version). +- **Hermetic expansion test**: set the env override to a known `C…` address, expand, and + assert the generated tokens construct `mod_name::Client::new(env, &Address::from_str( + env, "C…"))`. No RPC. +- **Consumer caveat (documented, not code):** `import_contract!` bakes a *real network* + address, so it is for real / integration builds. In `soroban_sdk` unit tests the + dependency is registered at a fresh test-generated address, so the baked constant is + not usable there — unit tests should keep `import_contract_client!` + their own + `Client::new(env, &test_addr)`. This is why `registry-tansu-manager` (whose Tansu + address is deploy-time / stored) is **not** migrated to `import_contract!`. + +### G. Scope summary + +**In:** the new crate + macro; the 4-step resolution; `compile_error!` handling; pure + +expansion tests; macro rustdoc with a worked example. +**Out:** everything in Non-goals. + +## Open items to verify during implementation + +1. `soroban_sdk::Address::from_str` presence/signature in the exact pinned soroban-sdk + 27.0.0-rc.1 (checked against 26.0.0-rc.1; API expected stable). +2. Nesting `import_contract_client!` inside `import_contract!` output compiles cleanly; + if not, use the inline `contractimport!` fallback (§C). +3. Exact stdout format of `stellar registry fetch-contract-id` (currently + `println!("{contract_id}")` — a bare `C…` line; trim whitespace). From 696e2ef6b235e0618482fb9e3afdfa105daef644 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:16:50 -0400 Subject: [PATCH 02/31] docs: implementation plan for import_contract! macro (#419) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- .../plans/2026-07-02-import-contract-macro.md | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-import-contract-macro.md diff --git a/docs/superpowers/plans/2026-07-02-import-contract-macro.md b/docs/superpowers/plans/2026-07-02-import-contract-macro.md new file mode 100644 index 0000000..514b2aa --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-import-contract-macro.md @@ -0,0 +1,625 @@ +# `import_contract!` Macro Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `stellar_registry::import_contract!(env, name)` proc-macro that returns a type-safe soroban `Client` already bound to the named contract's deployed on-chain address, resolved at build time. + +**Architecture:** A new `proc-macro = true` crate `stellar-registry-macro` in the `cli` workspace, re-exported from `stellar-registry`. The macro delegates wasm/type generation to the existing `import_contract_client!`, resolves the deployed address at build time (env override → `.id` cache → `stellar registry fetch-contract-id` shell-out, gated by `STELLAR_NO_REGISTRY`), and emits `mod_name::Client::new(env, &Address::from_str(env, "C…"))`. + +**Tech Stack:** Rust (edition 2024), `syn` 2 / `quote` / `proc-macro2`, `stellar-build` (target-dir/network), `stellar-strkey` (address validation), the `stellar` CLI (`registry fetch-contract-id`). + +**Design spec:** `docs/superpowers/specs/2026-07-02-import-contract-macro-design.md`. **Issue:** stellar-scaffold/cli#419. + +## Global Constraints + +- Rust **edition 2024** (matches the existing `stellar-registry` crate). +- **Strict clippy pedantic** — code must pass `just clippy` (`-Dclippy::pedantic`). +- Dep versions (match `stellar-scaffold-macro` 0.8.14): `proc-macro2 = "1.0"`, `quote = "1.0"`, `syn = { version = "2", features = ["full"] }`, `stellar-build` (workspace `0.0.6`), `stellar-strkey` (workspace `0.0.15`). +- The macro crate **must not** depend on `soroban-sdk`; it emits `::soroban_sdk::…` paths that resolve in the consumer crate. +- Address lookup is by **name only** (deployed instances are named, not versioned); the wasm keeps version semantics via the delegated `import_contract_client!`. +- Preserve hyphens in the value passed to `fetch-contract-id` (`PrefixedName` does no `_`/`-` normalization); only the *module name* gets `-`→`_`. + +--- + +### Task 1: New crate skeleton, workspace wiring, and pure helpers + +**Files:** +- Create: `crates/stellar-registry-macro/Cargo.toml` +- Create: `crates/stellar-registry-macro/src/lib.rs` +- Modify: `Cargo.toml` (root workspace — add three build deps + the path dep) +- Test: unit tests inside `crates/stellar-registry-macro/src/lib.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Produces (used by later tasks): `fn mod_name_from(&str) -> String`, `fn split_version(&str) -> (String, Option)`, `fn env_var_name(&str) -> String`, `fn validate_contract_id(&str) -> Result`, `fn cache_id_path(&Path, &str) -> PathBuf`, `fn manifest() -> PathBuf`. + +- [ ] **Step 1: Create the crate manifest** + +Create `crates/stellar-registry-macro/Cargo.toml`: + +```toml +[package] +name = "stellar-registry-macro" +version = "0.0.1" +edition = "2024" +description = "The import_contract! macro for the 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 } +stellar-strkey = { workspace = true } + +[lints] +workspace = true +``` + +- [ ] **Step 2: Wire the workspace dependencies** + +In the root `Cargo.toml` `[workspace.dependencies]`, add under `# Local crates`: + +```toml +stellar-registry-macro = { path = "crates/stellar-registry-macro" } +``` + +and add these three build-macro deps (they are not yet in the workspace): + +```toml +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2", features = ["full"] } +``` + +(`stellar-strkey = "0.0.15"` and `stellar-build = "0.0.6"` already exist in `[workspace.dependencies]`.) + +- [ ] **Step 3: Write the failing helper tests** + +Create `crates/stellar-registry-macro/src/lib.rs` with the test module first: + +```rust +#[cfg(test)] +mod helpers { + use super::*; + use std::path::Path; + + // A real, valid contract strkey (from soroban-sdk docs). + const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + + #[test] + fn mod_name_strips_prefix_and_hyphens() { + assert_eq!(mod_name_from("unverified/registry_tansu_manager"), "registry_tansu_manager"); + assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); + assert_eq!(mod_name_from("a/b/c"), "c"); + assert_eq!(mod_name_from("registry"), "registry"); + } + + #[test] + fn split_version_optional_v() { + assert_eq!(split_version("our_dao@v0.1.0"), ("our_dao".into(), Some("0.1.0".into()))); + assert_eq!(split_version("x@1.2.3"), ("x".into(), Some("1.2.3".into()))); + assert_eq!(split_version("x"), ("x".into(), None)); + } + + #[test] + fn env_var_name_uppercases_and_sanitizes() { + assert_eq!(env_var_name("registry_tansu_manager"), "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER"); + assert_eq!(env_var_name("guess_the_number"), "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER"); + } + + #[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_id_path_is_wasm_sibling() { + assert_eq!(cache_id_path(Path::new("target"), "our_dao"), Path::new("target/our_dao.id")); + } +} +``` + +- [ ] **Step 4: Run the tests to verify they fail to compile** + +Run: `cargo test -p stellar-registry-macro` +Expected: FAIL — `cannot find function mod_name_from` (and the others). + +- [ ] **Step 5: Implement the helpers** + +Prepend to `crates/stellar-registry-macro/src/lib.rs` (above the test module): + +```rust +//! The `import_contract!` proc-macro: resolve a named Stellar Registry contract +//! to a type-safe client already bound to its deployed on-chain address. +extern crate proc_macro; + +use std::{ + env, + path::{Path, PathBuf}, +}; + +/// Path to the compiling crate's `Cargo.toml`. +fn manifest() -> PathBuf { + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) + .join("Cargo.toml") +} + +/// Rust module identifier from a (possibly channel-prefixed) registry name: +/// final `/`-segment with `-` replaced by `_`. +fn mod_name_from(name_part: &str) -> String { + name_part + .rsplit('/') + .next() + .unwrap_or(name_part) + .replace('-', "_") +} + +/// Split `"name@v1.2.3"` / `"name@1.2.3"` into `(name, version-without-leading-v)`. +fn split_version(raw: &str) -> (String, Option) { + match raw.split_once('@') { + Some((name, ver)) => ( + name.to_string(), + Some(ver.strip_prefix('v').unwrap_or(ver).to_string()), + ), + None => (raw.to_string(), None), + } +} + +/// Env var a caller can set to bypass the network: +/// `STELLAR_CONTRACT_ID_`, NAME = uppercased module name with any +/// non-alphanumeric replaced by `_`. +fn env_var_name(mod_name: &str) -> String { + let sanitized: String = mod_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }) + .collect(); + format!("STELLAR_CONTRACT_ID_{sanitized}") +} + +/// 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:?}")) +} + +/// `/.id` — sibling of the wasm the client imports. +/// Keyed by name only: a deployed instance's address is version-independent. +fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { + target_dir.join(mod_name).with_extension("id") +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cargo test -p stellar-registry-macro` +Expected: PASS (5 tests in `helpers`). + +- [ ] **Step 7: Commit** + +```bash +git add crates/stellar-registry-macro Cargo.toml +git commit -m "feat: stellar-registry-macro crate skeleton + pure helpers" +``` + +--- + +### Task 2: Build-time address resolution + +**Files:** +- Modify: `crates/stellar-registry-macro/src/lib.rs` +- Test: `#[cfg(test)]` module in the same file + +**Interfaces:** +- Consumes: `validate_contract_id` (Task 1). +- Produces: `fn resolve_address(env_lookup, env_var, cache, no_registry, fetch) -> Result` (all IO injected) and `fn fetch_contract_id(&str) -> Result` (real shell-out, used by Task 4). + +- [ ] **Step 1: Write the failing resolution tests** + +Add to `crates/stellar-registry-macro/src/lib.rs`: + +```rust +#[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 env_override_wins() { + let got = resolve_address( + |k| (k == "STELLAR_CONTRACT_ID_FOO").then(|| A.to_string()), + "STELLAR_CONTRACT_ID_FOO", + Some(B.to_string()), + false, + no_fetch, + ); + assert_eq!(got.unwrap(), A); + } + + #[test] + fn cache_used_when_no_env() { + let got = resolve_address(|_| None, "X", Some(B.to_string()), false, no_fetch); + assert_eq!(got.unwrap(), B); + } + + #[test] + fn no_registry_errors_without_env_or_cache() { + let got = resolve_address(|_| None, "X", None, true, no_fetch); + assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); + } + + #[test] + fn fetch_is_last_resort() { + let got = resolve_address(|_| None, "X", None, false, || Ok(A.to_string())); + assert_eq!(got.unwrap(), A); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p stellar-registry-macro resolution` +Expected: FAIL — `cannot find function resolve_address`. + +- [ ] **Step 3: Implement resolution + shell-out** + +Add to `crates/stellar-registry-macro/src/lib.rs` (above the test modules): + +```rust +use std::process::Command; + +/// Resolve the deployed address, first hit wins. All IO is injected so the +/// precedence is unit-testable without a network or filesystem. +fn resolve_address( + env_lookup: impl Fn(&str) -> Option, + env_var: &str, + cache: Option, + no_registry: bool, + fetch: impl FnOnce() -> Result, +) -> Result { + if let Some(v) = env_lookup(env_var) { + return validate_contract_id(&v); + } + if let Some(c) = cache { + return validate_contract_id(&c); + } + if no_registry { + return Err(format!( + "No cached contract id and STELLAR_NO_REGISTRY=1 so not checking the Registry. \ + Set {env_var}, or run `stellar registry fetch-contract-id ` and rebuild." + )); + } + validate_contract_id(&fetch()?) +} + +/// Shell out to the `stellar` CLI to look up a deployed contract's id by name. +/// Network selection is delegated to the CLI's own config (`STELLAR_NETWORK`). +fn fetch_contract_id(lookup_name: &str) -> Result { + let out = Command::new("stellar") + .args(["registry", "fetch-contract-id", lookup_name]) + .output() + .map_err(|e| { + format!( + "failed to run `stellar registry fetch-contract-id`: {e}. \ + Install it with `cargo install stellar-registry-cli` and try again." + ) + })?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(format!( + "Could not resolve a contract id for `{lookup_name}`. \ + Check the name & network and try again (https://stellar.rgstry.xyz), \ + run `stellar registry fetch-contract-id {lookup_name}` yourself, \ + or set STELLAR_NO_REGISTRY=1 to skip the registry lookup.\n{}", + String::from_utf8_lossy(&out.stderr) + )) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p stellar-registry-macro resolution` +Expected: PASS (4 tests). `fetch_contract_id` is exercised in Task 4 / manual integration, not unit tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/stellar-registry-macro/src/lib.rs +git commit -m "feat: build-time address resolution for import_contract!" +``` + +--- + +### Task 3: Macro input parsing and code generation + +**Files:** +- Modify: `crates/stellar-registry-macro/src/lib.rs` +- Test: `#[cfg(test)]` module in the same file + +**Interfaces:** +- Consumes: `mod_name_from`, `split_version` (Task 1). +- Produces: `struct Input { env: Expr, name_raw: String, name_span: Span }` (implements `syn::parse::Parse`) and `fn expand(&Expr, &str, &Ident, &str) -> proc_macro2::TokenStream` (used by Task 4). + +- [ ] **Step 1: Write the failing codegen test** + +Add to `crates/stellar-registry-macro/src/lib.rs`: + +```rust +#[cfg(test)] +mod codegen { + use super::*; + use quote::quote; + use syn::{parse2, Ident}; + use proc_macro2::Span; + + const A: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + + #[test] + fn parses_env_and_string_name() { + let input: Input = parse2(quote!(env, "unverified/our_dao@v0.1.0")).unwrap(); + assert_eq!(input.name_raw, "unverified/our_dao@v0.1.0"); + } + + #[test] + fn parses_env_and_ident_name() { + let input: Input = parse2(quote!(env, registry)).unwrap(); + assert_eq!(input.name_raw, "registry"); + } + + #[test] + fn expand_emits_delegation_and_bound_client() { + let env: syn::Expr = parse2(quote!(env)).unwrap(); + let ident = Ident::new("our_dao", Span::call_site()); + let out = expand(&env, "unverified/our_dao@v0.1.0", &ident, A).to_string(); + assert!(out.contains("import_contract_client"), "delegates wasm import: {out}"); + assert!(out.contains("\"unverified/our_dao@v0.1.0\""), "passes original name: {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}"); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p stellar-registry-macro codegen` +Expected: FAIL — `cannot find type Input` / `cannot find function expand`. + +- [ ] **Step 3: Implement parsing and codegen** + +Add to `crates/stellar-registry-macro/src/lib.rs` (above the test modules): + +```rust +use proc_macro2::Span; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + Expr, Ident, LitStr, Token, +}; + +/// `import_contract!(env_expr, name)` — `name` is a bare ident or a string +/// literal using the same grammar as `import_contract_client!`. +struct Input { + env: Expr, + name_raw: String, + name_span: Span, +} + +impl Parse for Input { + fn parse(input: ParseStream) -> syn::Result { + let env: Expr = input.parse()?; + input.parse::()?; + let name_span = input.span(); + let name_raw = if input.peek(LitStr) { + input.parse::()?.value() + } else { + input.parse::()?.to_string() + }; + Ok(Self { env, name_raw, name_span }) + } +} + +/// Emit a block expression: delegate wasm/type generation to +/// `import_contract_client!`, then construct the client bound to the baked +/// address. `name_raw` is passed through verbatim (version included) so the +/// delegated macro resolves the matching wasm. +fn expand(env: &Expr, name_raw: &str, mod_ident: &Ident, address: &str) -> proc_macro2::TokenStream { + quote! { + { + ::stellar_registry::import_contract_client!(#name_raw); + let __env: &::soroban_sdk::Env = #env; + #mod_ident::Client::new( + __env, + &::soroban_sdk::Address::from_str(__env, #address), + ) + } + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p stellar-registry-macro codegen` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/stellar-registry-macro/src/lib.rs +git commit -m "feat: parse import_contract! input and generate the bound client" +``` + +--- + +### Task 4: `#[proc_macro]` entry point + re-export from `stellar-registry` + +**Files:** +- Modify: `crates/stellar-registry-macro/src/lib.rs` (add the `#[proc_macro]` fn) +- Modify: `crates/stellar-registry/Cargo.toml` (depend on the macro crate) +- Modify: `crates/stellar-registry/src/lib.rs` (re-export) + +**Interfaces:** +- Consumes: `Input`, `expand` (Task 3); `resolve_address`, `fetch_contract_id` (Task 2); `mod_name_from`, `split_version`, `env_var_name`, `cache_id_path`, `manifest` (Task 1). +- Produces: `stellar_registry::import_contract!` usable by consumers. + +- [ ] **Step 1: Implement the proc-macro entry point** + +Add to `crates/stellar-registry-macro/src/lib.rs`: + +```rust +use proc_macro::TokenStream; +use syn::parse_macro_input; + +/// Generate a type-safe client for a deployed, registry-named contract, +/// already bound to its on-chain address (resolved at build time). +/// +/// ```ignore +/// // `env: &Env` +/// let dao = stellar_registry::import_contract!(env, our_dao); +/// dao.create_proposal(/* ... */); +/// ``` +/// +/// `name` accepts the same forms as [`import_contract_client!`]: +/// `our_dao`, `"unverified/our_dao"`, `"our_dao@v1.0.0"`. +/// +/// The address is resolved at build time: `STELLAR_CONTRACT_ID_` env +/// override → `target/stellar//.id` cache → +/// `stellar registry fetch-contract-id`. `STELLAR_NO_REGISTRY=1` forbids the +/// network call. Because a real on-chain address is baked in, use this in +/// real / integration builds; in `soroban_sdk` unit tests keep +/// `import_contract_client!` plus your own `Client::new(env, &test_addr)`. +#[proc_macro] +pub fn import_contract(input: TokenStream) -> TokenStream { + let Input { env, name_raw, name_span } = parse_macro_input!(input as Input); + let (name_part, _version) = split_version(&name_raw); + let mod_name = mod_name_from(&name_part); + let mod_ident = Ident::new(&mod_name, name_span); + let evar = env_var_name(&mod_name); + + let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); + let cache_path = stellar_build::get_target_dir(&manifest()) + .ok() + .map(|dir| cache_id_path(&dir, &mod_name)); + let cache = cache_path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()); + + let resolved = resolve_address( + |k| env::var(k).ok(), + &evar, + cache, + no_registry, + || { + let addr = fetch_contract_id(&name_part)?; + if let Some(p) = &cache_path { + let _ = std::fs::write(p, &addr); + } + Ok(addr) + }, + ); + + match resolved { + Ok(address) => expand(&env, &name_raw, &mod_ident, &address).into(), + Err(msg) => syn::Error::new(name_span, msg).to_compile_error().into(), + } +} +``` + +- [ ] **Step 2: Verify the crate builds** + +Run: `cargo build -p stellar-registry-macro` +Expected: builds clean. + +- [ ] **Step 3: Depend on the macro crate from `stellar-registry`** + +In `crates/stellar-registry/Cargo.toml`, under `[dependencies]`, add: + +```toml +stellar-registry-macro = { workspace = true } +``` + +- [ ] **Step 4: Re-export the macro** + +In `crates/stellar-registry/src/lib.rs`, add below the existing `pub use stellar_scaffold_macro::*;`: + +```rust +pub use stellar_registry_macro::import_contract; +``` + +- [ ] **Step 5: Verify `stellar-registry` builds with the re-export** + +Run: `cargo build -p stellar-registry` +Expected: builds clean; `stellar_registry::import_contract` is now public. + +- [ ] **Step 6: Run the full crate test suite** + +Run: `cargo test -p stellar-registry-macro` +Expected: PASS — all `helpers`, `resolution`, `codegen` tests green. + +- [ ] **Step 7: Commit** + +```bash +git add crates/stellar-registry-macro/src/lib.rs crates/stellar-registry/Cargo.toml crates/stellar-registry/src/lib.rs +git commit -m "feat: wire import_contract! proc-macro and re-export from stellar-registry" +``` + +--- + +### Task 5: Lint pass, docs build, and end-to-end integration check + +**Files:** +- Modify: `crates/stellar-registry-macro/src/lib.rs` (only if clippy/doc requires) + +**Interfaces:** none new. + +- [ ] **Step 1: Run pedantic clippy across the workspace** + +Run: `just clippy` +Expected: no warnings. Fix any pedantic findings in `stellar-registry-macro` (likely `must_use`, `uninlined_format_args`) until clean. + +- [ ] **Step 2: Build docs** + +Run: `cargo doc -p stellar-registry-macro --no-deps` +Expected: builds; the `import_contract` rustdoc renders with the example. + +- [ ] **Step 3: Manual hermetic expansion check (no network)** + +In a scratch soroban contract crate that has a wasm fixture staged at `target/stellar//hello_world.wasm` and `soroban-sdk` + `stellar-registry` deps, add: + +```rust +let _c = stellar_registry::import_contract!(env, hello_world); // env: &Env +``` + +Run: `STELLAR_CONTRACT_ID_HELLO_WORLD=CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322 STELLAR_NETWORK=local cargo build` +Expected: compiles — confirms delegation to `import_contract_client!`, the `::soroban_sdk::Address::from_str` path, and env-override resolution all resolve together. If `::soroban_sdk::Address::from_str` is absent in the pinned soroban-sdk (verify item §C.1 of the spec), switch the emitted address construction to `::soroban_sdk::Address::from_string(&::soroban_sdk::String::from_str(__env, #address))` and re-run. + +- [ ] **Step 4: Commit any lint/doc fixes** + +```bash +git add crates/stellar-registry-macro/src/lib.rs +git commit -m "chore: satisfy pedantic clippy and docs for import_contract!" +``` + +--- + +## Follow-ups (out of scope for this plan) + +- **Publish** `stellar-registry-macro` and bump `stellar-registry` so the `contracts` repo can consume `import_contract!` across crates.io (per the cross-repo wiring in the umbrella CLAUDE.md). +- **Automated integration test** in the `contracts` repo (which already stages wasm fixtures and builds before test) exercising `import_contract!` end-to-end against a local registry. +- **Address lockfile** (`registry-ids.toml` + a refresh command) for reproducible multi-network builds, layered on the `.id` cache. + +## Self-Review + +- **Spec coverage:** crate layout (Task 1/4), macro surface (Task 3), delegation codegen (Task 3/4), 4-step resolution incl. env override / cache / `STELLAR_NO_REGISTRY` / shell-out (Task 2/4), `compile_error!` handling (Task 2/4), pure + codegen tests (Task 1–3), rustdoc + unit-test caveat (Task 4/5). All spec sections map to a task. +- **Type consistency:** `resolve_address` / `fetch_contract_id` / `expand` / `Input` signatures are identical where produced (Task 2/3) and consumed (Task 4). `cache_id_path` takes `(&Path, &str)` everywhere (version dropped by design — address is version-independent). +- **Placeholder scan:** every code step contains complete code; the only conditional is the documented soroban-sdk API fallback in Task 5 Step 3, tied to spec verify-item §C.1. From 8703f8782d1d2b5314a0c8bf52068cb21a056b42 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:29:21 -0400 Subject: [PATCH 03/31] feat: stellar-registry-macro crate skeleton + pure helpers Implement the import_contract! macro helper functions in TDD style: - Create crate skeleton with proc-macro lib type - Wire workspace dependencies (proc-macro2, quote, syn) - Add 6 pure helper functions: mod_name_from, split_version, env_var_name, validate_contract_id, cache_id_path, manifest - Add comprehensive unit tests for all helpers (5 tests, all passing) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- Cargo.lock | 11 +++ Cargo.toml | 5 ++ crates/stellar-registry-macro/Cargo.toml | 20 +++++ crates/stellar-registry-macro/src/lib.rs | 102 +++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 crates/stellar-registry-macro/Cargo.toml create mode 100644 crates/stellar-registry-macro/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c73a35b..b8a931f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4300,6 +4300,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "stellar-registry-macro" +version = "0.0.1" +dependencies = [ + "proc-macro2", + "quote", + "stellar-build", + "stellar-strkey 0.0.16", + "syn 2.0.117", +] + [[package]] name = "stellar-registry-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c50dbc7..e5cee29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ 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" } # Cross-repo deps from scaffold-stellar/cli (crates.io for published libs, # git for stellar-scaffold-test which is `publish = false`). @@ -25,6 +26,10 @@ 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" diff --git a/crates/stellar-registry-macro/Cargo.toml b/crates/stellar-registry-macro/Cargo.toml new file mode 100644 index 0000000..888f8b6 --- /dev/null +++ b/crates/stellar-registry-macro/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "stellar-registry-macro" +version = "0.0.1" +edition = "2024" +description = "The import_contract! macro for the 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 } +stellar-strkey = { workspace = true } + +[lints] +workspace = true diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs new file mode 100644 index 0000000..8e6dbb9 --- /dev/null +++ b/crates/stellar-registry-macro/src/lib.rs @@ -0,0 +1,102 @@ +//! The `import_contract!` proc-macro: resolve a named Stellar Registry contract +//! to a type-safe client already bound to its deployed on-chain address. +extern crate proc_macro; + +use std::{ + env, + path::{Path, PathBuf}, +}; + +/// Path to the compiling crate's `Cargo.toml`. +fn manifest() -> PathBuf { + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) + .join("Cargo.toml") +} + +/// Rust module identifier from a (possibly channel-prefixed) registry name: +/// final `/`-segment with `-` replaced by `_`. +fn mod_name_from(name_part: &str) -> String { + name_part + .rsplit('/') + .next() + .unwrap_or(name_part) + .replace('-', "_") +} + +/// Split `"name@v1.2.3"` / `"name@1.2.3"` into `(name, version-without-leading-v)`. +fn split_version(raw: &str) -> (String, Option) { + match raw.split_once('@') { + Some((name, ver)) => ( + name.to_string(), + Some(ver.strip_prefix('v').unwrap_or(ver).to_string()), + ), + None => (raw.to_string(), None), + } +} + +/// Env var a caller can set to bypass the network: +/// `STELLAR_CONTRACT_ID_`, NAME = uppercased module name with any +/// non-alphanumeric replaced by `_`. +fn env_var_name(mod_name: &str) -> String { + let sanitized: String = mod_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }) + .collect(); + format!("STELLAR_CONTRACT_ID_{sanitized}") +} + +/// 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:?}")) +} + +/// `/.id` — sibling of the wasm the client imports. +/// Keyed by name only: a deployed instance's address is version-independent. +fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { + target_dir.join(mod_name).with_extension("id") +} + +#[cfg(test)] +mod helpers { + use super::*; + use std::path::Path; + + // A real, valid contract strkey (from soroban-sdk docs). + const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + + #[test] + fn mod_name_strips_prefix_and_hyphens() { + assert_eq!(mod_name_from("unverified/registry_tansu_manager"), "registry_tansu_manager"); + assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); + assert_eq!(mod_name_from("a/b/c"), "c"); + assert_eq!(mod_name_from("registry"), "registry"); + } + + #[test] + fn split_version_optional_v() { + assert_eq!(split_version("our_dao@v0.1.0"), ("our_dao".into(), Some("0.1.0".into()))); + assert_eq!(split_version("x@1.2.3"), ("x".into(), Some("1.2.3".into()))); + assert_eq!(split_version("x"), ("x".into(), None)); + } + + #[test] + fn env_var_name_uppercases_and_sanitizes() { + assert_eq!(env_var_name("registry_tansu_manager"), "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER"); + assert_eq!(env_var_name("guess_the_number"), "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER"); + } + + #[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_id_path_is_wasm_sibling() { + assert_eq!(cache_id_path(Path::new("target"), "our_dao"), Path::new("target/our_dao.id")); + } +} From d007a837a0411696a277d9cf491c117f27e902bf Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:37:35 -0400 Subject: [PATCH 04/31] fix: format stellar-registry-macro and commit Cargo.lock Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- crates/stellar-registry-macro/src/lib.rs | 38 +++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index 8e6dbb9..ff62ca6 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -40,7 +40,13 @@ fn split_version(raw: &str) -> (String, Option) { fn env_var_name(mod_name: &str) -> String { let sanitized: String = mod_name .chars() - .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) .collect(); format!("STELLAR_CONTRACT_ID_{sanitized}") } @@ -69,7 +75,10 @@ mod helpers { #[test] fn mod_name_strips_prefix_and_hyphens() { - assert_eq!(mod_name_from("unverified/registry_tansu_manager"), "registry_tansu_manager"); + assert_eq!( + mod_name_from("unverified/registry_tansu_manager"), + "registry_tansu_manager" + ); assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); assert_eq!(mod_name_from("a/b/c"), "c"); assert_eq!(mod_name_from("registry"), "registry"); @@ -77,26 +86,41 @@ mod helpers { #[test] fn split_version_optional_v() { - assert_eq!(split_version("our_dao@v0.1.0"), ("our_dao".into(), Some("0.1.0".into()))); + assert_eq!( + split_version("our_dao@v0.1.0"), + ("our_dao".into(), Some("0.1.0".into())) + ); assert_eq!(split_version("x@1.2.3"), ("x".into(), Some("1.2.3".into()))); assert_eq!(split_version("x"), ("x".into(), None)); } #[test] fn env_var_name_uppercases_and_sanitizes() { - assert_eq!(env_var_name("registry_tansu_manager"), "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER"); - assert_eq!(env_var_name("guess_the_number"), "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER"); + assert_eq!( + env_var_name("registry_tansu_manager"), + "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER" + ); + assert_eq!( + env_var_name("guess_the_number"), + "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER" + ); } #[test] fn validate_contract_id_trims_and_checks() { - assert_eq!(validate_contract_id(&format!(" {VALID}\n")).unwrap(), VALID); + 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_id_path_is_wasm_sibling() { - assert_eq!(cache_id_path(Path::new("target"), "our_dao"), Path::new("target/our_dao.id")); + assert_eq!( + cache_id_path(Path::new("target"), "our_dao"), + Path::new("target/our_dao.id") + ); } } From 17af95f75f1328cec02979c3471c479ae83745b2 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:40:04 -0400 Subject: [PATCH 05/31] feat: build-time address resolution for import_contract! Add resolve_address (fully injectable for testability) and fetch_contract_id (shell-out to stellar CLI) to implement address resolution precedence: env var > cache > registry lookup. Includes 4 unit tests verifying the resolution order (env override, cache fallback, no-registry error, fetch). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- crates/stellar-registry-macro/src/lib.rs | 91 ++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index ff62ca6..46ad657 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -5,6 +5,7 @@ extern crate proc_macro; use std::{ env, path::{Path, PathBuf}, + process::Command, }; /// Path to the compiling crate's `Cargo.toml`. @@ -65,6 +66,55 @@ fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { target_dir.join(mod_name).with_extension("id") } +/// Resolve the deployed address, first hit wins. All IO is injected so the +/// precedence is unit-testable without a network or filesystem. +fn resolve_address( + env_lookup: impl Fn(&str) -> Option, + env_var: &str, + cache: Option, + no_registry: bool, + fetch: impl FnOnce() -> Result, +) -> Result { + if let Some(v) = env_lookup(env_var) { + return validate_contract_id(&v); + } + if let Some(c) = cache { + return validate_contract_id(&c); + } + if no_registry { + return Err(format!( + "No cached contract id and STELLAR_NO_REGISTRY=1 so not checking the Registry. \ + Set {env_var}, or run `stellar registry fetch-contract-id ` and rebuild." + )); + } + validate_contract_id(&fetch()?) +} + +/// Shell out to the `stellar` CLI to look up a deployed contract's id by name. +/// Network selection is delegated to the CLI's own config (`STELLAR_NETWORK`). +fn fetch_contract_id(lookup_name: &str) -> Result { + let out = Command::new("stellar") + .args(["registry", "fetch-contract-id", lookup_name]) + .output() + .map_err(|e| { + format!( + "failed to run `stellar registry fetch-contract-id`: {e}. \ + Install it with `cargo install stellar-registry-cli` and try again." + ) + })?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(format!( + "Could not resolve a contract id for `{lookup_name}`. \ + Check the name & network and try again (https://stellar.rgstry.xyz), \ + run `stellar registry fetch-contract-id {lookup_name}` yourself, \ + or set STELLAR_NO_REGISTRY=1 to skip the registry lookup.\n{}", + String::from_utf8_lossy(&out.stderr) + )) + } +} + #[cfg(test)] mod helpers { use super::*; @@ -124,3 +174,44 @@ mod helpers { ); } } + +#[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 env_override_wins() { + let got = resolve_address( + |k| (k == "STELLAR_CONTRACT_ID_FOO").then(|| A.to_string()), + "STELLAR_CONTRACT_ID_FOO", + Some(B.to_string()), + false, + no_fetch, + ); + assert_eq!(got.unwrap(), A); + } + + #[test] + fn cache_used_when_no_env() { + let got = resolve_address(|_| None, "X", Some(B.to_string()), false, no_fetch); + assert_eq!(got.unwrap(), B); + } + + #[test] + fn no_registry_errors_without_env_or_cache() { + let got = resolve_address(|_| None, "X", None, true, no_fetch); + assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); + } + + #[test] + fn fetch_is_last_resort() { + let got = resolve_address(|_| None, "X", None, false, || Ok(A.to_string())); + assert_eq!(got.unwrap(), A); + } +} From bb1379f7ac56d77450c090e15abfcf8df61b7561 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:46:49 -0400 Subject: [PATCH 06/31] feat: parse import_contract! input and generate the bound client Implement struct Input with syn::parse::Parse trait to parse the macro's input (env expression and contract name as bare ident or string literal). Implement fn expand() to emit a block expression that delegates wasm import to import_contract_client! macro and constructs the client bound to the resolved contract address. Add codegen test module with 3 tests covering parsing and code generation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- crates/stellar-registry-macro/src/lib.rs | 101 +++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index 46ad657..2980cf3 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -115,6 +115,61 @@ fn fetch_contract_id(lookup_name: &str) -> Result { } } +use proc_macro2::Span; +use quote::quote; +use syn::{ + Expr, Ident, LitStr, Token, + parse::{Parse, ParseStream}, +}; + +/// `import_contract!(env_expr, name)` — `name` is a bare ident or a string +/// literal using the same grammar as `import_contract_client!`. +struct Input { + env: Expr, + name_raw: String, + name_span: Span, +} + +impl Parse for Input { + fn parse(input: ParseStream) -> syn::Result { + let env: Expr = input.parse()?; + input.parse::()?; + let name_span = input.span(); + let name_raw = if input.peek(LitStr) { + input.parse::()?.value() + } else { + input.parse::()?.to_string() + }; + Ok(Self { + env, + name_raw, + name_span, + }) + } +} + +/// Emit a block expression: delegate wasm/type generation to +/// `import_contract_client!`, then construct the client bound to the baked +/// address. `name_raw` is passed through verbatim (version included) so the +/// delegated macro resolves the matching wasm. +fn expand( + env: &Expr, + name_raw: &str, + mod_ident: &Ident, + address: &str, +) -> proc_macro2::TokenStream { + quote! { + { + ::stellar_registry::import_contract_client!(#name_raw); + let __env: &::soroban_sdk::Env = #env; + #mod_ident::Client::new( + __env, + &::soroban_sdk::Address::from_str(__env, #address), + ) + } + } +} + #[cfg(test)] mod helpers { use super::*; @@ -215,3 +270,49 @@ mod resolution { assert_eq!(got.unwrap(), A); } } + +#[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@v0.1.0")).unwrap(); + assert_eq!(input.name_raw, "unverified/our_dao@v0.1.0"); + } + + #[test] + fn parses_env_and_ident_name() { + let input: Input = parse2(quote!(env, registry)).unwrap(); + assert_eq!(input.name_raw, "registry"); + } + + #[test] + fn expand_emits_delegation_and_bound_client() { + let env: syn::Expr = parse2(quote!(env)).unwrap(); + let ident = Ident::new("our_dao", Span::call_site()); + let out = expand(&env, "unverified/our_dao@v0.1.0", &ident, A).to_string(); + assert!( + out.contains("import_contract_client"), + "delegates wasm import: {out}" + ); + assert!( + out.contains("\"unverified/our_dao@v0.1.0\""), + "passes original name: {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}"); + } +} From f03b88270065ec46c9ee06099af7bcb0a0f9610c Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 2 Jul 2026 23:53:51 -0400 Subject: [PATCH 07/31] feat: wire import_contract! proc-macro and re-export from stellar-registry Adds the #[proc_macro] entry point that ties together the pure helpers, build-time address resolution, and codegen from the prior tasks, and re-exports it as stellar_registry::import_contract for consumers. This wires every previously-dead helper into the entry point, so no dead_code warnings remain and no #[allow(dead_code)] was needed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- Cargo.lock | 1 + crates/stellar-registry-macro/src/lib.rs | 60 ++++++++++++++++++++++++ crates/stellar-registry/Cargo.toml | 1 + crates/stellar-registry/src/lib.rs | 1 + 4 files changed, 63 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b8a931f..a8aee78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4250,6 +4250,7 @@ dependencies = [ name = "stellar-registry" version = "0.0.11" dependencies = [ + "stellar-registry-macro", "stellar-scaffold-macro", ] diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index 2980cf3..4ccd3c3 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -2,6 +2,7 @@ //! to a type-safe client already bound to its deployed on-chain address. extern crate proc_macro; +use proc_macro::TokenStream; use std::{ env, path::{Path, PathBuf}, @@ -120,6 +121,7 @@ use quote::quote; use syn::{ Expr, Ident, LitStr, Token, parse::{Parse, ParseStream}, + parse_macro_input, }; /// `import_contract!(env_expr, name)` — `name` is a bare ident or a string @@ -170,6 +172,64 @@ fn expand( } } +/// Generate a type-safe client for a deployed, registry-named contract, +/// already bound to its on-chain address (resolved at build time). +/// +/// ```ignore +/// // `env: &Env` +/// let dao = stellar_registry::import_contract!(env, our_dao); +/// dao.create_proposal(/* ... */); +/// ``` +/// +/// `name` accepts the same forms as [`import_contract_client!`]: +/// `our_dao`, `"unverified/our_dao"`, `"our_dao@v1.0.0"`. +/// +/// The address is resolved at build time: `STELLAR_CONTRACT_ID_` env +/// override → `target/stellar//.id` cache → +/// `stellar registry fetch-contract-id`. `STELLAR_NO_REGISTRY=1` forbids the +/// network call. Because a real on-chain address is baked in, use this in +/// real / integration builds; in `soroban_sdk` unit tests keep +/// `import_contract_client!` plus your own `Client::new(env, &test_addr)`. +#[proc_macro] +pub fn import_contract(input: TokenStream) -> TokenStream { + let Input { + env, + name_raw, + name_span, + } = parse_macro_input!(input as Input); + let (name_part, _version) = split_version(&name_raw); + let mod_name = mod_name_from(&name_part); + let mod_ident = Ident::new(&mod_name, name_span); + let evar = env_var_name(&mod_name); + + let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); + let cache_path = stellar_build::get_target_dir(&manifest()) + .ok() + .map(|dir| cache_id_path(&dir, &mod_name)); + let cache = cache_path + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()); + + let resolved = resolve_address( + |k| env::var(k).ok(), + &evar, + cache, + no_registry, + || { + let addr = fetch_contract_id(&name_part)?; + if let Some(p) = &cache_path { + let _ = std::fs::write(p, &addr); + } + Ok(addr) + }, + ); + + match resolved { + Ok(address) => expand(&env, &name_raw, &mod_ident, &address).into(), + Err(msg) => syn::Error::new(name_span, msg).to_compile_error().into(), + } +} + #[cfg(test)] mod helpers { use super::*; diff --git a/crates/stellar-registry/Cargo.toml b/crates/stellar-registry/Cargo.toml index b013e54..8f0f348 100644 --- a/crates/stellar-registry/Cargo.toml +++ b/crates/stellar-registry/Cargo.toml @@ -14,3 +14,4 @@ crate-type = ["rlib"] [dependencies] stellar-scaffold-macro = { workspace = true } +stellar-registry-macro = { workspace = true } diff --git a/crates/stellar-registry/src/lib.rs b/crates/stellar-registry/src/lib.rs index 471dc5a..82e45d1 100644 --- a/crates/stellar-registry/src/lib.rs +++ b/crates/stellar-registry/src/lib.rs @@ -2,4 +2,5 @@ //! `stellar-registry` is a collection of tools to help integrate with //! existing smart contracts on Stellar. //! +pub use stellar_registry_macro::import_contract; pub use stellar_scaffold_macro::*; From 83d77ede427fa94734feb340886bb992f3cd2f57 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Fri, 3 Jul 2026 00:12:44 -0400 Subject: [PATCH 08/31] fix: guard empty contract names and validate before caching the id Prevents a compiler panic on names like "foo/" or "@v1.0.0" (empty module identifier) and stops a malformed fetch-contract-id response from poisoning the .id cache. Findings from the final whole-branch review. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- crates/stellar-registry-macro/src/lib.rs | 33 +++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index 4ccd3c3..142bf98 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -25,6 +25,21 @@ fn mod_name_from(name_part: &str) -> String { .replace('-', "_") } +/// A name whose derived module identifier is empty (e.g. `""`, `"foo/"`, +/// `"@v1.0.0"`) cannot form a valid Rust identifier. Reject it up front so the +/// macro emits a `compile_error!` instead of panicking inside `Ident::new`. +fn check_mod_name(mod_name: &str) -> Result<(), String> { + if mod_name.is_empty() { + Err( + "import_contract! needs a contract name whose module identifier is non-empty \ + (got an empty name, or one like \"foo/\" or \"@v1.0.0\")" + .to_string(), + ) + } else { + Ok(()) + } +} + /// Split `"name@v1.2.3"` / `"name@1.2.3"` into `(name, version-without-leading-v)`. fn split_version(raw: &str) -> (String, Option) { match raw.split_once('@') { @@ -199,6 +214,9 @@ pub fn import_contract(input: TokenStream) -> TokenStream { } = parse_macro_input!(input as Input); let (name_part, _version) = split_version(&name_raw); let mod_name = mod_name_from(&name_part); + if let Err(msg) = check_mod_name(&mod_name) { + return syn::Error::new(name_span, msg).to_compile_error().into(); + } let mod_ident = Ident::new(&mod_name, name_span); let evar = env_var_name(&mod_name); @@ -216,7 +234,9 @@ pub fn import_contract(input: TokenStream) -> TokenStream { cache, no_registry, || { - let addr = fetch_contract_id(&name_part)?; + // Validate before caching so a malformed CLI response can't poison + // the .id cache (which is read before re-fetching on later builds). + let addr = validate_contract_id(&fetch_contract_id(&name_part)?)?; if let Some(p) = &cache_path { let _ = std::fs::write(p, &addr); } @@ -288,6 +308,17 @@ mod helpers { Path::new("target/our_dao.id") ); } + + #[test] + fn check_mod_name_rejects_empty_identifiers() { + assert!(check_mod_name("our_dao").is_ok()); + assert!(check_mod_name("").is_err()); + assert!(check_mod_name(&mod_name_from("foo/")).is_err()); + // split_version("@v1.0.0") produces ("", Some("v1.0.0")), so mod_name_from("") + // returns an empty string. + let (name_part, _) = split_version("@v1.0.0"); + assert!(check_mod_name(&mod_name_from(&name_part)).is_err()); + } } #[cfg(test)] From 67493c8da27afc2e741feba44973c01191787599 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Tue, 14 Jul 2026 06:47:53 -0400 Subject: [PATCH 09/31] fix(macro): resolve import_contract! by contract, not by wasm-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #17 ("stop slopping me bro"): the macro was built by analogy to import_contract_client! and conflated a contract with its wasm. - Drop @version. A deployed contract has no version (only a wasm does); reject `@` with a clear compile_error!. - Stop delegating codegen to import_contract_client!(name), which resolves a wasm by name and wrongly assumes contract-name == wasm-name. Instead fetch the deployed contract's own on-chain wasm by address (`stellar contract fetch --id`) and inline `soroban_sdk::contractimport!` — so a registered contract whose wasm was never published still works. - Fail compilation if the contract is flagged as compromised (#38, #52). No on-chain getter exists, so read the registry's ContractEntry persistent ledger entry directly via RPC (key (Symbol("CR"), ); a 3-element vec == flagged), behind a new `fetch-contract-id --reject-flagged`. - Online builds no longer trust the cached .id, so a contract flagged after the first build can't slip through; the .id/.wasm caches are the offline (STELLAR_NO_REGISTRY=1) fallback, and env override / offline are the only explicit opt-outs of the flag check. - Self-contained rustdoc (no import_contract_client! reference). New: stellar_registry_build::Registry::is_contract_flagged (raw ledger read), fetch_contract_id --reject-flagged. Design/plan docs get a post-review revision note. Verified end to end on testnet: fetch-contract-id --reject-flagged returns the address when unflagged, errors "contract `oz` is flagged as compromised" once flag_contract sets it, and the plain lookup is unchanged. Unit tests + pedantic clippy pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q5b9Nj9oNVLjeRetxCeyxP --- crates/stellar-registry-build/src/error.rs | 4 + crates/stellar-registry-build/src/registry.rs | 41 +++ .../src/commands/fetch_contract_id.rs | 15 + crates/stellar-registry-macro/src/lib.rs | 266 +++++++++++------- .../plans/2026-07-02-import-contract-macro.md | 7 + ...2026-07-02-import-contract-macro-design.md | 25 ++ 6 files changed, 263 insertions(+), 95 deletions(-) diff --git a/crates/stellar-registry-build/src/error.rs b/crates/stellar-registry-build/src/error.rs index d20712a..668cc2a 100644 --- a/crates/stellar-registry-build/src/error.rs +++ b/crates/stellar-registry-build/src/error.rs @@ -15,4 +15,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/registry.rs b/crates/stellar-registry-build/src/registry.rs index e0f2a21..4863794 100644 --- a/crates/stellar-registry-build/src/registry.rs +++ b/crates/stellar-registry-build/src/registry.rs @@ -44,6 +44,47 @@ impl Registry { )) } + /// 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: String = name + .chars() + .map(|c| if c == '_' { '-' } else { c.to_ascii_lowercase() }) + .collect(); + 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/fetch_contract_id.rs b/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs index c33a883..459c6ac 100644 --- a/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs +++ b/crates/stellar-registry-cli/src/commands/fetch_contract_id.rs @@ -11,6 +11,12 @@ pub struct Cmd { /// E.g. `unverified/` pub contract_name: PrefixedName, + /// Fail (non-zero exit) if the contract is flagged as compromised in the + /// registry. Used by `import_contract!` to refuse importing a flagged + /// contract at build time. + #[arg(long)] + pub reject_flagged: bool, + #[command(flatten)] pub config: global::Args, } @@ -23,6 +29,8 @@ 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")] + ContractFlagged(String), } impl Cmd { @@ -34,6 +42,13 @@ impl Cmd { pub async fn fetch_contract_id(&self) -> Result { let registry = self.contract_name.registry(&self.config).await?; + if self.reject_flagged + && registry + .is_contract_flagged(&self.contract_name.name) + .await? + { + return Err(Error::ContractFlagged(self.contract_name.to_string())); + } Ok(registry.fetch_contract_id(&self.contract_name.name).await?) } } diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index 142bf98..f38cc01 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -1,5 +1,6 @@ //! The `import_contract!` proc-macro: resolve a named Stellar Registry contract -//! to a type-safe client already bound to its deployed on-chain address. +//! to a type-safe client already bound to its deployed on-chain address, with +//! the client types generated from the deployed contract's own wasm. extern crate proc_macro; use proc_macro::TokenStream; @@ -25,14 +26,14 @@ fn mod_name_from(name_part: &str) -> String { .replace('-', "_") } -/// A name whose derived module identifier is empty (e.g. `""`, `"foo/"`, -/// `"@v1.0.0"`) cannot form a valid Rust identifier. Reject it up front so the -/// macro emits a `compile_error!` instead of panicking inside `Ident::new`. +/// A name whose derived module identifier is empty (e.g. `""`, `"foo/"`) cannot +/// form a valid Rust identifier. Reject it up front so the macro emits a +/// `compile_error!` instead of panicking inside `Ident::new`. fn check_mod_name(mod_name: &str) -> Result<(), String> { if mod_name.is_empty() { Err( "import_contract! needs a contract name whose module identifier is non-empty \ - (got an empty name, or one like \"foo/\" or \"@v1.0.0\")" + (got an empty name, or one like \"foo/\")" .to_string(), ) } else { @@ -40,14 +41,16 @@ fn check_mod_name(mod_name: &str) -> Result<(), String> { } } -/// Split `"name@v1.2.3"` / `"name@1.2.3"` into `(name, version-without-leading-v)`. -fn split_version(raw: &str) -> (String, Option) { - match raw.split_once('@') { - Some((name, ver)) => ( - name.to_string(), - Some(ver.strip_prefix('v').unwrap_or(ver).to_string()), - ), - None => (raw.to_string(), None), +/// A deployed contract has no version — only a wasm does. Reject the `@version` +/// syntax `import_contract_client!` accepts, pointing the caller at the plain form. +fn reject_version(raw: &str) -> Result<(), String> { + if raw.contains('@') { + 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)`." + )) + } else { + Ok(()) } } @@ -76,14 +79,25 @@ fn validate_contract_id(s: &str) -> Result { .map_err(|_| format!("not a valid contract id (C… strkey): {t:?}")) } -/// `/.id` — sibling of the wasm the client imports. -/// Keyed by name only: a deployed instance's address is version-independent. +/// `/.id` — cached deployed address. Keyed by name only: +/// a deployed instance's address is version-independent. fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { target_dir.join(mod_name).with_extension("id") } -/// Resolve the deployed address, first hit wins. All IO is injected so the -/// precedence is unit-testable without a network or filesystem. +/// `/.wasm` — the deployed contract's wasm, fetched by +/// address, that `contractimport!` reads to generate the client types. +fn cache_wasm_path(target_dir: &Path, mod_name: &str) -> PathBuf { + target_dir.join(mod_name).with_extension("wasm") +} + +/// Resolve the deployed address. Precedence, first hit wins: +/// 1. `STELLAR_CONTRACT_ID_` env override — explicit, no flag check. +/// 2. `STELLAR_NO_REGISTRY=1` — offline: the `.id` cache, no flag check. +/// 3. otherwise online — `fetch` (which also fails if the contract is flagged) +/// and refresh the cache. The cache is deliberately NOT consulted online, +/// so a contract flagged after the first build cannot slip through a stale +/// `.id`. All IO is injected so precedence is unit-testable offline. fn resolve_address( env_lookup: impl Fn(&str) -> Option, env_var: &str, @@ -94,23 +108,29 @@ fn resolve_address( if let Some(v) = env_lookup(env_var) { return validate_contract_id(&v); } - if let Some(c) = cache { - return validate_contract_id(&c); - } if no_registry { - return Err(format!( - "No cached contract id and STELLAR_NO_REGISTRY=1 so not checking the Registry. \ - Set {env_var}, or run `stellar registry fetch-contract-id ` and rebuild." - )); + return match cache { + Some(c) => validate_contract_id(&c), + None => Err(format!( + "STELLAR_NO_REGISTRY=1 but no cached contract id. Set {env_var}, or build \ + online once (which caches it), then rebuild offline." + )), + }; } validate_contract_id(&fetch()?) } -/// Shell out to the `stellar` CLI to look up a deployed contract's id by name. -/// Network selection is delegated to the CLI's own config (`STELLAR_NETWORK`). +/// Shell out to the `stellar` CLI to look up a deployed contract's id by name, +/// failing if it is flagged as compromised (`--reject-flagged`). Network +/// selection is delegated to the CLI's own config (`STELLAR_NETWORK`). fn fetch_contract_id(lookup_name: &str) -> Result { let out = Command::new("stellar") - .args(["registry", "fetch-contract-id", lookup_name]) + .args([ + "registry", + "fetch-contract-id", + lookup_name, + "--reject-flagged", + ]) .output() .map_err(|e| { format!( @@ -122,10 +142,33 @@ fn fetch_contract_id(lookup_name: &str) -> Result { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } else { Err(format!( - "Could not resolve a contract id for `{lookup_name}`. \ - Check the name & network and try again (https://stellar.rgstry.xyz), \ - run `stellar registry fetch-contract-id {lookup_name}` yourself, \ - or set STELLAR_NO_REGISTRY=1 to skip the registry lookup.\n{}", + "Could not resolve `{lookup_name}`. It may not be registered, may be flagged as \ + compromised, or the network may be wrong (https://stellar.rgstry.xyz). Run \ + `stellar registry fetch-contract-id {lookup_name}` yourself, or set \ + STELLAR_NO_REGISTRY=1 with a cached id to skip the registry lookup.\n{}", + String::from_utf8_lossy(&out.stderr) + )) + } +} + +/// 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) )) } @@ -140,7 +183,7 @@ use syn::{ }; /// `import_contract!(env_expr, name)` — `name` is a bare ident or a string -/// literal using the same grammar as `import_contract_client!`. +/// literal (optionally channel-prefixed, e.g. `"unverified/our_dao"`). struct Input { env: Expr, name_raw: String, @@ -165,19 +208,20 @@ impl Parse for Input { } } -/// Emit a block expression: delegate wasm/type generation to -/// `import_contract_client!`, then construct the client bound to the baked -/// address. `name_raw` is passed through verbatim (version included) so the -/// delegated macro resolves the matching wasm. +/// Emit a block expression: generate the client types from the deployed +/// contract's own wasm, then construct the client bound to the baked address. fn expand( env: &Expr, - name_raw: &str, mod_ident: &Ident, + wasm_path: &str, address: &str, ) -> proc_macro2::TokenStream { quote! { { - ::stellar_registry::import_contract_client!(#name_raw); + mod #mod_ident { + use super::soroban_sdk; + soroban_sdk::contractimport!(file = #wasm_path); + } let __env: &::soroban_sdk::Env = #env; #mod_ident::Client::new( __env, @@ -187,8 +231,9 @@ fn expand( } } -/// Generate a type-safe client for a deployed, registry-named contract, -/// already bound to its on-chain address (resolved at build time). +/// 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` @@ -196,15 +241,23 @@ fn expand( /// dao.create_proposal(/* ... */); /// ``` /// -/// `name` accepts the same forms as [`import_contract_client!`]: -/// `our_dao`, `"unverified/our_dao"`, `"our_dao@v1.0.0"`. +/// `name` is a bare ident or string literal, optionally channel-prefixed +/// (`import_contract!(env, "unverified/our_dao")`). A deployed contract has no +/// version, so **no `@version` is accepted**. The client types are generated +/// from the deployed contract's *own* on-chain wasm, so a contract whose wasm +/// was never published to the registry still works. +/// +/// Resolved at build time: +/// - **address** — `STELLAR_CONTRACT_ID_` env override → (offline only) +/// `target/stellar//.id` cache → `stellar registry +/// fetch-contract-id`. The online path **fails compilation if the contract is +/// flagged as compromised** in the registry. +/// - **wasm** — `stellar contract fetch --id
`, cached beside the id. /// -/// The address is resolved at build time: `STELLAR_CONTRACT_ID_` env -/// override → `target/stellar//.id` cache → -/// `stellar registry fetch-contract-id`. `STELLAR_NO_REGISTRY=1` forbids the -/// network call. Because a real on-chain address is baked in, use this in -/// real / integration builds; in `soroban_sdk` unit tests keep -/// `import_contract_client!` plus your own `Client::new(env, &test_addr)`. +/// `STELLAR_NO_REGISTRY=1` forbids the network calls (requires a cached id + +/// wasm, and skips the flag check). Because a real on-chain address is baked in, +/// use this in real / integration builds; if the named contract is redeployed or +/// upgraded, clear the cache (`cargo clean`) and rebuild. #[proc_macro] pub fn import_contract(input: TokenStream) -> TokenStream { let Input { @@ -212,42 +265,62 @@ pub fn import_contract(input: TokenStream) -> TokenStream { name_raw, name_span, } = parse_macro_input!(input as Input); - let (name_part, _version) = split_version(&name_raw); - let mod_name = mod_name_from(&name_part); + + let err = |msg: String| -> TokenStream { + syn::Error::new(name_span, msg).to_compile_error().into() + }; + + if let Err(msg) = reject_version(&name_raw) { + return err(msg); + } + let mod_name = mod_name_from(&name_raw); if let Err(msg) = check_mod_name(&mod_name) { - return syn::Error::new(name_span, msg).to_compile_error().into(); + return err(msg); } let mod_ident = Ident::new(&mod_name, name_span); let evar = env_var_name(&mod_name); let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); - let cache_path = stellar_build::get_target_dir(&manifest()) - .ok() - .map(|dir| cache_id_path(&dir, &mod_name)); - let cache = cache_path - .as_ref() - .and_then(|p| std::fs::read_to_string(p).ok()); - - let resolved = resolve_address( + let target_dir = match stellar_build::get_target_dir(&manifest()) { + Ok(dir) => dir, + Err(e) => return err(format!("could not determine the cargo target dir: {e}")), + }; + let id_path = cache_id_path(&target_dir, &mod_name); + let wasm_path = cache_wasm_path(&target_dir, &mod_name); + let cache = std::fs::read_to_string(&id_path).ok(); + + // 1. Resolve the deployed address (and, online, enforce the flag check). + let address = match resolve_address( |k| env::var(k).ok(), &evar, cache, no_registry, || { - // Validate before caching so a malformed CLI response can't poison - // the .id cache (which is read before re-fetching on later builds). - let addr = validate_contract_id(&fetch_contract_id(&name_part)?)?; - if let Some(p) = &cache_path { - let _ = std::fs::write(p, &addr); - } + let addr = validate_contract_id(&fetch_contract_id(&name_raw)?)?; + let _ = std::fs::write(&id_path, &addr); Ok(addr) }, - ); - - match resolved { - Ok(address) => expand(&env, &name_raw, &mod_ident, &address).into(), - Err(msg) => syn::Error::new(name_span, msg).to_compile_error().into(), + ) { + Ok(a) => a, + Err(msg) => return err(msg), + }; + + // 2. Ensure the deployed contract's wasm is on disk for `contractimport!`. + if !wasm_path.exists() { + if no_registry { + return err(format!( + "STELLAR_NO_REGISTRY=1 but no cached wasm at {}. Build online once (which \ + fetches it) then rebuild offline.", + wasm_path.display() + )); + } + if let Err(msg) = fetch_wasm(&address, &wasm_path) { + return err(msg); + } } + + // 3. Generate the client from that wasm and bind it to the address. + expand(&env, &mod_ident, &wasm_path.to_string_lossy(), &address).into() } #[cfg(test)] @@ -270,13 +343,11 @@ mod helpers { } #[test] - fn split_version_optional_v() { - assert_eq!( - split_version("our_dao@v0.1.0"), - ("our_dao".into(), Some("0.1.0".into())) - ); - assert_eq!(split_version("x@1.2.3"), ("x".into(), Some("1.2.3".into()))); - assert_eq!(split_version("x"), ("x".into(), None)); + fn reject_version_rejects_at() { + assert!(reject_version("our_dao").is_ok()); + assert!(reject_version("unverified/our_dao").is_ok()); + assert!(reject_version("our_dao@v1.0.0").is_err()); + assert!(reject_version("our_dao@1.0.0").is_err()); } #[test] @@ -302,11 +373,15 @@ mod helpers { } #[test] - fn cache_id_path_is_wasm_sibling() { + fn cache_paths_are_target_siblings() { assert_eq!( cache_id_path(Path::new("target"), "our_dao"), Path::new("target/our_dao.id") ); + assert_eq!( + cache_wasm_path(Path::new("target"), "our_dao"), + Path::new("target/our_dao.wasm") + ); } #[test] @@ -314,10 +389,6 @@ mod helpers { assert!(check_mod_name("our_dao").is_ok()); assert!(check_mod_name("").is_err()); assert!(check_mod_name(&mod_name_from("foo/")).is_err()); - // split_version("@v1.0.0") produces ("", Some("v1.0.0")), so mod_name_from("") - // returns an empty string. - let (name_part, _) = split_version("@v1.0.0"); - assert!(check_mod_name(&mod_name_from(&name_part)).is_err()); } } @@ -344,20 +415,21 @@ mod resolution { } #[test] - fn cache_used_when_no_env() { - let got = resolve_address(|_| None, "X", Some(B.to_string()), false, no_fetch); + fn offline_uses_cache() { + let got = resolve_address(|_| None, "X", Some(B.to_string()), true, no_fetch); assert_eq!(got.unwrap(), B); } #[test] - fn no_registry_errors_without_env_or_cache() { + fn offline_errors_without_env_or_cache() { let got = resolve_address(|_| None, "X", None, true, no_fetch); assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); } #[test] - fn fetch_is_last_resort() { - let got = resolve_address(|_| None, "X", None, false, || Ok(A.to_string())); + fn online_fetches_and_ignores_stale_cache() { + // A cached id must NOT short-circuit the online fetch (+ flag check). + let got = resolve_address(|_| None, "X", Some(B.to_string()), false, || Ok(A.to_string())); assert_eq!(got.unwrap(), A); } } @@ -373,8 +445,8 @@ mod codegen { #[test] fn parses_env_and_string_name() { - let input: Input = parse2(quote!(env, "unverified/our_dao@v0.1.0")).unwrap(); - assert_eq!(input.name_raw, "unverified/our_dao@v0.1.0"); + let input: Input = parse2(quote!(env, "unverified/our_dao")).unwrap(); + assert_eq!(input.name_raw, "unverified/our_dao"); } #[test] @@ -384,17 +456,21 @@ mod codegen { } #[test] - fn expand_emits_delegation_and_bound_client() { + 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, "unverified/our_dao@v0.1.0", &ident, A).to_string(); + let out = expand(&env, &ident, "/tmp/target/stellar/local/our_dao.wasm", A).to_string(); + assert!( + out.contains("contractimport"), + "generates types from the wasm: {out}" + ); assert!( - out.contains("import_contract_client"), - "delegates wasm import: {out}" + !out.contains("import_contract_client"), + "does NOT delegate to import_contract_client!: {out}" ); assert!( - out.contains("\"unverified/our_dao@v0.1.0\""), - "passes original name: {out}" + out.contains("our_dao.wasm"), + "references the fetched wasm: {out}" ); assert!( out.contains("our_dao :: Client :: new"), diff --git a/docs/superpowers/plans/2026-07-02-import-contract-macro.md b/docs/superpowers/plans/2026-07-02-import-contract-macro.md index 514b2aa..45b312d 100644 --- a/docs/superpowers/plans/2026-07-02-import-contract-macro.md +++ b/docs/superpowers/plans/2026-07-02-import-contract-macro.md @@ -10,6 +10,13 @@ **Design spec:** `docs/superpowers/specs/2026-07-02-import-contract-macro-design.md`. **Issue:** stellar-scaffold/cli#419. +> **Revised post-review (2026-07-14, PR #17):** the "delegates wasm/type +> generation to `import_contract_client!`" architecture above was rejected. See +> the design spec's "Revision (post-review)" section — the macro takes no +> `@version`, generates types from the deployed contract's own on-chain wasm +> (`stellar contract fetch --id`), and fails compilation if the contract is +> flagged (`fetch-contract-id --reject-flagged`, a raw `ContractEntry` ledger read). + ## Global Constraints - Rust **edition 2024** (matches the existing `stellar-registry` crate). diff --git a/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md b/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md index 805d58e..41587b3 100644 --- a/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md +++ b/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md @@ -4,6 +4,31 @@ **Repo:** `stellar-registry/cli` **Date:** 2026-07-02 +## Revision (post-review, 2026-07-14) + +Review (`stellar-registry/cli#17`) rejected the original codegen approach below. +`import_contract!` resolves a *contract*, not a *wasm*, and the two are not the +same thing. The implemented design differs from §B–§D as follows: + +1. **No `@version`.** A deployed contract has no version (only a wasm does). The + macro rejects `@` with a `compile_error!`. +2. **No delegation to `import_contract_client!`.** That resolves a wasm by + *name*, wrongly assuming the contract's name equals its wasm's name. Instead + the macro fetches the deployed contract's *own* on-chain wasm by address + (`stellar contract fetch --id --out-file …`) and inlines + `soroban_sdk::contractimport!(file = …)` — so a contract whose wasm was never + published to the registry still works (the §C "Fallback" is now the primary). +3. **Fail compilation if the contract is flagged** (`#38`, `#52`). No on-chain + getter exists, so the build reads the registry's `ContractEntry` persistent + ledger entry directly via RPC (key `(Symbol("CR"), )`; a + 3-element vec = flagged) behind a new `fetch-contract-id --reject-flagged`. +4. **Resolution precedence** (supersedes §D): `STELLAR_CONTRACT_ID_` env + override → (only under `STELLAR_NO_REGISTRY=1`) the `.id` cache → online + `fetch-contract-id --reject-flagged`. Online builds do **not** trust the `.id` + cache, so a contract flagged after the first build cannot slip through; env + override and offline mode are the explicit opt-outs of the flag check. +5. **Self-contained rustdoc** — no reference to `import_contract_client!`. + ## Goal Make cross-contract calls to a *named* Stellar Registry contract a one-liner: From 41e4ad4c803d221b68d9fbf400e10098b28aadaf Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Wed, 15 Jul 2026 12:48:41 +0200 Subject: [PATCH 10/31] feat: move import_contract_client here --- crates/stellar-registry-macro/src/asset.rs | 0 crates/stellar-registry-macro/src/contract.rs | 485 ++++++++++++++++++ .../src/contract_client.rs | 391 ++++++++++++++ crates/stellar-registry-macro/src/lib.rs | 485 +----------------- 4 files changed, 882 insertions(+), 479 deletions(-) create mode 100644 crates/stellar-registry-macro/src/asset.rs create mode 100644 crates/stellar-registry-macro/src/contract.rs create mode 100644 crates/stellar-registry-macro/src/contract_client.rs diff --git a/crates/stellar-registry-macro/src/asset.rs b/crates/stellar-registry-macro/src/asset.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs new file mode 100644 index 0000000..ff06342 --- /dev/null +++ b/crates/stellar-registry-macro/src/contract.rs @@ -0,0 +1,485 @@ +use proc_macro::TokenStream; +use std::{ + env, + path::{Path, PathBuf}, + process::Command, +}; + +use proc_macro2::Span; +use quote::quote; +use syn::{ + Expr, Ident, LitStr, Token, + parse::{Parse, ParseStream}, + parse_macro_input, +}; + +/// Path to the compiling crate's `Cargo.toml`. +fn manifest() -> PathBuf { + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) + .join("Cargo.toml") +} + +/// Rust module identifier from a (possibly channel-prefixed) registry name: +/// final `/`-segment with `-` replaced by `_`. +fn mod_name_from(name_part: &str) -> String { + name_part + .rsplit('/') + .next() + .unwrap_or(name_part) + .replace('-', "_") +} + +/// A name whose derived module identifier is empty (e.g. `""`, `"foo/"`) cannot +/// form a valid Rust identifier. Reject it up front so the macro emits a +/// `compile_error!` instead of panicking inside `Ident::new`. +fn check_mod_name(mod_name: &str) -> Result<(), String> { + if mod_name.is_empty() { + Err( + "import_contract! needs a contract name whose module identifier is non-empty \ + (got an empty name, or one like \"foo/\")" + .to_string(), + ) + } else { + Ok(()) + } +} + +/// A deployed contract has no version — only a wasm does. Reject the `@version` +/// syntax `import_contract_client!` accepts, pointing the caller at the plain form. +fn reject_version(raw: &str) -> Result<(), String> { + if raw.contains('@') { + 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)`." + )) + } else { + Ok(()) + } +} + +/// Env var a caller can set to bypass the network: +/// `STELLAR_CONTRACT_ID_`, NAME = uppercased module name with any +/// non-alphanumeric replaced by `_`. +fn env_var_name(mod_name: &str) -> String { + let sanitized: String = mod_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect(); + format!("STELLAR_CONTRACT_ID_{sanitized}") +} + +/// 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:?}")) +} + +/// `/.id` — cached deployed address. Keyed by name only: +/// a deployed instance's address is version-independent. +fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { + target_dir.join(mod_name).with_extension("id") +} + +/// `/.wasm` — the deployed contract's wasm, fetched by +/// address, that `contractimport!` reads to generate the client types. +fn cache_wasm_path(target_dir: &Path, mod_name: &str) -> PathBuf { + target_dir.join(mod_name).with_extension("wasm") +} + +/// Resolve the deployed address. Precedence, first hit wins: +/// 1. `STELLAR_CONTRACT_ID_` env override — explicit, no flag check. +/// 2. `STELLAR_NO_REGISTRY=1` — offline: the `.id` cache, no flag check. +/// 3. otherwise online — `fetch` (which also fails if the contract is flagged) +/// and refresh the cache. The cache is deliberately NOT consulted online, +/// so a contract flagged after the first build cannot slip through a stale +/// `.id`. All IO is injected so precedence is unit-testable offline. +fn resolve_address( + env_lookup: impl Fn(&str) -> Option, + env_var: &str, + cache: Option, + no_registry: bool, + fetch: impl FnOnce() -> Result, +) -> Result { + if let Some(v) = env_lookup(env_var) { + return validate_contract_id(&v); + } + if no_registry { + return match cache { + Some(c) => validate_contract_id(&c), + None => Err(format!( + "STELLAR_NO_REGISTRY=1 but no cached contract id. Set {env_var}, or build \ + online once (which caches it), then rebuild offline." + )), + }; + } + validate_contract_id(&fetch()?) +} + +/// Shell out to the `stellar` CLI to look up a deployed contract's id by name, +/// failing if it is flagged as compromised (`--reject-flagged`). Network +/// selection is delegated to the CLI's own config (`STELLAR_NETWORK`). +fn fetch_contract_id(lookup_name: &str) -> Result { + let out = Command::new("stellar") + .args([ + "registry", + "fetch-contract-id", + lookup_name, + "--reject-flagged", + ]) + .output() + .map_err(|e| { + format!( + "failed to run `stellar registry fetch-contract-id`: {e}. \ + Install it with `cargo install stellar-registry-cli` and try again." + ) + })?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(format!( + "Could not resolve `{lookup_name}`. It may not be registered, may be flagged as \ + compromised, or the network may be wrong (https://stellar.rgstry.xyz). Run \ + `stellar registry fetch-contract-id {lookup_name}` yourself, or set \ + STELLAR_NO_REGISTRY=1 with a cached id to skip the registry lookup.\n{}", + String::from_utf8_lossy(&out.stderr) + )) + } +} + +/// 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) + )) + } +} + +/// `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_raw: String, + name_span: Span, +} + +impl Parse for Input { + fn parse(input: ParseStream) -> syn::Result { + let env: Expr = input.parse()?; + input.parse::()?; + let name_span = input.span(); + let name_raw = if input.peek(LitStr) { + input.parse::()?.value() + } else { + input.parse::()?.to_string() + }; + Ok(Self { + env, + name_raw, + name_span, + }) + } +} + +/// Emit a block expression: generate the client types from the deployed +/// contract's own wasm, then construct the client bound to the baked address. +fn expand( + env: &Expr, + mod_ident: &Ident, + wasm_path: &str, + address: &str, +) -> proc_macro2::TokenStream { + quote! { + { + mod #mod_ident { + use super::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), + ) + } + } +} + +/// 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(/* ... */); +/// ``` +/// +/// `name` is a bare ident or string literal, optionally channel-prefixed +/// (`import_contract!(env, "unverified/our_dao")`). A deployed contract has no +/// version, so **no `@version` is accepted**. The client types are generated +/// from the deployed contract's *own* on-chain wasm, so a contract whose wasm +/// was never published to the registry still works. +/// +/// Resolved at build time: +/// - **address** — `STELLAR_CONTRACT_ID_` env override → (offline only) +/// `target/stellar//.id` cache → `stellar registry +/// fetch-contract-id`. The online path **fails compilation if the contract is +/// flagged as compromised** in the registry. +/// - **wasm** — `stellar contract fetch --id
`, cached beside the id. +/// +/// `STELLAR_NO_REGISTRY=1` forbids the network calls (requires a cached id + +/// wasm, and skips the flag check). Because a real on-chain address is baked in, +/// use this in real / integration builds; if the named contract is redeployed or +/// upgraded, clear the cache (`cargo clean`) and rebuild. +#[proc_macro] +pub fn import_contract(input: TokenStream) -> TokenStream { + let Input { + env, + name_raw, + name_span, + } = parse_macro_input!(input as Input); + + let err = + |msg: String| -> TokenStream { syn::Error::new(name_span, msg).to_compile_error().into() }; + + if let Err(msg) = reject_version(&name_raw) { + return err(msg); + } + let mod_name = mod_name_from(&name_raw); + if let Err(msg) = check_mod_name(&mod_name) { + return err(msg); + } + let mod_ident = Ident::new(&mod_name, name_span); + let evar = env_var_name(&mod_name); + + let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); + let target_dir = match stellar_build::get_target_dir(&manifest()) { + Ok(dir) => dir, + Err(e) => return err(format!("could not determine the cargo target dir: {e}")), + }; + let id_path = cache_id_path(&target_dir, &mod_name); + let wasm_path = cache_wasm_path(&target_dir, &mod_name); + let cache = std::fs::read_to_string(&id_path).ok(); + + // 1. Resolve the deployed address (and, online, enforce the flag check). + let address = match resolve_address( + |k| env::var(k).ok(), + &evar, + cache, + no_registry, + || { + let addr = validate_contract_id(&fetch_contract_id(&name_raw)?)?; + let _ = std::fs::write(&id_path, &addr); + Ok(addr) + }, + ) { + Ok(a) => a, + Err(msg) => return err(msg), + }; + + // 2. Ensure the deployed contract's wasm is on disk for `contractimport!`. + if !wasm_path.exists() { + if no_registry { + return err(format!( + "STELLAR_NO_REGISTRY=1 but no cached wasm at {}. Build online once (which \ + fetches it) then rebuild offline.", + wasm_path.display() + )); + } + if let Err(msg) = fetch_wasm(&address, &wasm_path) { + return err(msg); + } + } + + // 3. Generate the client from that wasm and bind it to the address. + expand(&env, &mod_ident, &wasm_path.to_string_lossy(), &address).into() +} + +#[cfg(test)] +mod helpers { + use super::*; + use std::path::Path; + + // A real, valid contract strkey (from soroban-sdk docs). + const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; + + #[test] + fn mod_name_strips_prefix_and_hyphens() { + assert_eq!( + mod_name_from("unverified/registry_tansu_manager"), + "registry_tansu_manager" + ); + assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); + assert_eq!(mod_name_from("a/b/c"), "c"); + assert_eq!(mod_name_from("registry"), "registry"); + } + + #[test] + fn reject_version_rejects_at() { + assert!(reject_version("our_dao").is_ok()); + assert!(reject_version("unverified/our_dao").is_ok()); + assert!(reject_version("our_dao@v1.0.0").is_err()); + assert!(reject_version("our_dao@1.0.0").is_err()); + } + + #[test] + fn env_var_name_uppercases_and_sanitizes() { + assert_eq!( + env_var_name("registry_tansu_manager"), + "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER" + ); + assert_eq!( + env_var_name("guess_the_number"), + "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER" + ); + } + + #[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_target_siblings() { + assert_eq!( + cache_id_path(Path::new("target"), "our_dao"), + Path::new("target/our_dao.id") + ); + assert_eq!( + cache_wasm_path(Path::new("target"), "our_dao"), + Path::new("target/our_dao.wasm") + ); + } + + #[test] + fn check_mod_name_rejects_empty_identifiers() { + assert!(check_mod_name("our_dao").is_ok()); + assert!(check_mod_name("").is_err()); + assert!(check_mod_name(&mod_name_from("foo/")).is_err()); + } +} + +#[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 env_override_wins() { + let got = resolve_address( + |k| (k == "STELLAR_CONTRACT_ID_FOO").then(|| A.to_string()), + "STELLAR_CONTRACT_ID_FOO", + Some(B.to_string()), + false, + no_fetch, + ); + assert_eq!(got.unwrap(), A); + } + + #[test] + fn offline_uses_cache() { + let got = resolve_address(|_| None, "X", Some(B.to_string()), true, no_fetch); + assert_eq!(got.unwrap(), B); + } + + #[test] + fn offline_errors_without_env_or_cache() { + let got = resolve_address(|_| None, "X", None, true, no_fetch); + assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); + } + + #[test] + fn online_fetches_and_ignores_stale_cache() { + // A cached id must NOT short-circuit the online fetch (+ flag check). + let got = resolve_address( + |_| None, + "X", + Some(B.to_string()), + false, + || Ok(A.to_string()), + ); + assert_eq!(got.unwrap(), A); + } +} + +#[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 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/our_dao.wasm", A).to_string(); + assert!( + out.contains("contractimport"), + "generates types from the wasm: {out}" + ); + assert!( + !out.contains("import_contract_client"), + "does NOT delegate to import_contract_client!: {out}" + ); + assert!( + out.contains("our_dao.wasm"), + "references the fetched wasm: {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..f162e53 --- /dev/null +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -0,0 +1,391 @@ +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::quote; +use std::env; +use stellar_build::Network; +use syn::parse::{Parse, ParseStream, Result}; +use syn::{Ident, LitStr, parse_macro_input}; + +pub(crate) fn manifest() -> std::path::PathBuf { + std::path::PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) + .join("Cargo.toml") +} + +/// Generates a contract Client for a given contract. +/// The name should match a published contract or a contract in your current workspace. +/// +/// # Usage +/// +/// ```ignore +/// // For simple names (workspace contracts or registry names without hyphens): +/// import_contract_client!(registry); +/// +/// // For hyphenated names or channel-prefixed registry paths: +/// import_contract_client!("unverified/guess-the-number"); +/// +/// // For specific versions, use quotes. `v` is optional: +/// import_contract_client!("registry@v1.0.0"); +/// ``` +/// +/// When using a string literal, the module name is derived from the contract +/// name with hyphens replaced by underscores (e.g., `guess_the_number`). +/// +/// # Panics +/// +/// This function may panic in the following situations: +/// - If `stellar_build::get_target_dir()` fails to retrieve the target directory +/// - If the input tokens cannot be parsed as a valid identifier +/// - If the input tokens cannot be parsed as a valid identifier or string literal +/// - If the directory path cannot be canonicalized +/// - If the canonical path cannot be converted to a string +#[proc_macro] +pub fn import_contract_client(wasm_binary: TokenStream) -> TokenStream { + let WasmBinary { mod_name, file } = parse_macro_input!(wasm_binary as WasmBinary); + + quote! { + pub(crate) mod #mod_name { + #![allow(clippy::ref_option, clippy::too_many_arguments)] + use super::soroban_sdk; + soroban_sdk::contractimport!(file = #file); + } + } + .into() +} + +struct WasmBinary { + pub mod_name: Ident, + pub file: String, +} + +impl Parse for WasmBinary { + fn parse(input: ParseStream) -> Result { + let (lookup_name, mod_name, version) = parse_name_and_version(input)?; + let wasm_path = resolve_wasm_path(&lookup_name, &mod_name, version.as_deref())?; + let file = wasm_path.display().to_string(); + Ok(Self { mod_name, file }) + } +} + +/// Parse a contract name with an optional version specifier. +/// +/// Accepts an identifier like `registry` or a string like +/// `"unverified/guess-the-number"`, optionally followed by `@VERSION` +/// (e.g. `"registry@v1.4.0"` or `"registry@1.4.0"`). Returns the bare +/// lookup name, a sanitized module identifier, and the parsed version +/// (with any leading `v` stripped). +fn parse_name_and_version(input: ParseStream) -> Result<(String, Ident, Option)> { + let span = input.span(); + let raw = if input.peek(LitStr) { + input.parse::()?.value() + } else { + input.parse::()?.to_string() + }; + + if regex::Regex::new(r"(^/)|(/$)").unwrap().is_match(&raw) { + return Err(syn::Error::new( + span, + format!("bad leading/trailing slash: `{raw}`"), + )); + } + + // Split off optional version: "name@v1.4.0" or "name@1.4.0" + let (name_part, version) = match raw.split_once('@') { + Some((name, ver)) => { + let ver = ver.strip_prefix('v').unwrap_or(ver); + (name.to_string(), Some(ver.to_string())) + } + None => (raw, None), + }; + + // Derive a valid Rust identifier for the module name (no version) + // e.g. "unverified/guess-the-number" -> "guess_the_number" + let mod_name_str = name_part + .rsplit('/') + .next() + .unwrap_or(&name_part) + .replace('-', "_"); + let mod_name = Ident::new(&mod_name_str, span); + + Ok((name_part, mod_name, version)) +} + +fn build_local_wasm_path( + target_dir: &std::path::Path, + mod_name: &Ident, + version: Option<&str>, +) -> std::path::PathBuf { + let file_stem = match version { + Some(v) => format!("{}_{}", mod_name, v.replace('.', "_")), + None => mod_name.to_string(), + }; + target_dir.join(file_stem).with_extension("wasm") +} + +fn resolve_wasm_path( + lookup_name: &str, + mod_name: &Ident, + version: Option<&str>, +) -> Result { + let target_dir = stellar_build::get_target_dir(&manifest()).unwrap(); + let local_path = build_local_wasm_path(&target_dir, mod_name, version); + + // 1. Check local build target + if local_path.exists() { + return Ok(local_path.canonicalize().expect("canonicalize failed")); + } + + // 2. If STELLAR_NO_REGISTRY set to 1, error + if let Ok(v) = env::var("STELLAR_NO_REGISTRY") + && &v == "1" + { + return Err(syn::Error::new( + mod_name.span(), + format!( + "No local wasm found and STELLAR_NO_REGISTRY=1 so not checking Registry. \ + Download manually with `stellar registry download {lookup_name}`" + ), + )); + } + + // 3. if var absent or set to something else, try to download + download_from_registry(lookup_name, &local_path, mod_name.span(), version) +} + +fn download_from_registry( + lookup_name: &str, + local_path: &std::path::Path, + span: Span, + version: Option<&str>, +) -> Result { + // 1. create `target/stellar/[network]` directory, if not already present + let parent = local_path.parent().expect("no parent"); + if !parent.exists() { + std::fs::create_dir_all(parent).expect("creating parent directory failed"); + } + + // 2. download using `stellar registry download` + let mut args = vec![ + "registry".to_string(), + "download".to_string(), + lookup_name.to_string(), + "--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 status = std::process::Command::new("stellar") + .args(&args) + .status() + .expect( + "failed to execute `stellar registry download`; try `cargo install stellar-registry-cli` and try again", + ); + + // 3. check status + if status.success() && local_path.exists() { + Ok(local_path.canonicalize().expect("canonicalize failed")) + } else { + let local_path = local_path.display().to_string(); + Err(syn::Error::new( + span, + format!( + "Could not find Wasm `{lookup_name}`. Checked: \ + \n\n• {local_path} \ + \n• `stellar registry download {lookup_name}` \ + \n\nYou can: \ + \n\n1. check the name & network and try again (https://stellar.rgstry.xyz) \ + \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." + ), + )) + } +} + +/// Generates a contract Client for a given asset. +/// It is expected that the name of an asset, e.g. "native" or "USDC:G1...." +/// +/// # Panics +/// +#[proc_macro] +pub fn import_asset(input: TokenStream) -> TokenStream { + // Parse the input as a string literal + let input_str = syn::parse_macro_input!(input as syn::LitStr); + asset::parse_literal(&input_str, &Network::passphrase_from_env()).into() +} + +#[cfg(test)] +mod parse_name_and_version { + use super::*; + use syn::parse::Parser; + + #[test] + fn parse_simple_name() { + let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!(registry)) + .unwrap(); + assert_eq!(mod_name.to_string(), "registry"); + assert_eq!(lookup_name, "registry"); + } + + #[test] + fn parse_channel_hyphenated_name() { + let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("guess-the-number")) + .unwrap(); + assert_eq!(mod_name.to_string(), "guess_the_number"); + assert_eq!(lookup_name, "guess-the-number"); + } + + #[test] + fn parse_channel_prefixed_name() { + let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("unverified/guess-the-number")) + .unwrap(); + assert_eq!(mod_name.to_string(), "guess_the_number"); + assert_eq!(lookup_name, "unverified/guess-the-number"); + } + + #[test] + fn parse_channel_simple_name() { + let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("unverified/hello")) + .unwrap(); + assert_eq!(mod_name.to_string(), "hello"); + assert_eq!(lookup_name, "unverified/hello"); + } + + #[test] + fn parse_underscored_name() { + let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("my_contract")) + .unwrap(); + assert_eq!(mod_name, "my_contract"); + assert_eq!(lookup_name, "my_contract"); + } + + #[test] + fn error_trailing_slash() { + let err = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("unverified/")) + .unwrap_err(); + assert!( + err.to_string() + .contains("bad leading/trailing slash: `unverified/`"), + "unexpected error: {err}" + ); + } + + #[test] + fn error_leading_slash() { + let err = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("/guess-the-number")) + .unwrap_err(); + assert!( + err.to_string() + .contains("bad leading/trailing slash: `/guess-the-number`"), + "unexpected error: {err}" + ); + } + + #[test] + fn multiple_slashes_returns_final_as_mod_name() { + let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("a/b/c")) + .unwrap(); + assert_eq!(mod_name, "c"); + assert_eq!(lookup_name, "a/b/c"); + } + + #[test] + #[should_panic(expected = "Ident is not allowed to be empty")] + fn error_empty_string() { + (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("")) + .unwrap(); + } + + #[test] + #[should_panic(expected = "not a valid Ident")] + fn error_starts_with_digit() { + (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("123bad")) + .unwrap(); + } + + #[test] + #[should_panic(expected = "not a valid Ident")] + fn error_invalid_characters() { + (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("hello world")) + .unwrap(); + } + + #[test] + #[should_panic(expected = "not a valid Ident")] + fn error_channel_prefixed_starts_with_digit() { + (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("unverified/1bad")) + .unwrap(); + } + + #[test] + fn main_channel_with_version() { + let (lookup_name, mod_name, version) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("registry@v1.0.1")) + .unwrap(); + assert_eq!(mod_name, "registry"); + assert_eq!(lookup_name, "registry"); + assert_eq!(&version.unwrap(), "1.0.1"); + } + + #[test] + fn unverified_channel_with_version() { + let (lookup_name, mod_name, version) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("unverified/guess-the-number@0.4.0")) + .unwrap(); + assert_eq!(mod_name, "guess_the_number"); + assert_eq!(lookup_name, "unverified/guess-the-number"); + assert_eq!(&version.unwrap(), "0.4.0"); + } + + #[test] + fn prerelease_version() { + let (lookup_name, mod_name, version) = (|input: ParseStream| parse_name_and_version(input)) + .parse2(quote!("registry@1.0.0-rc.1")) + .unwrap(); + assert_eq!(mod_name, "registry"); + assert_eq!(lookup_name, "registry"); + assert_eq!(&version.unwrap(), "1.0.0-rc.1"); + } +} + +#[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"), &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"), &ident("registry"), None); + assert_eq!(path, Path::new("target/registry.wasm")); + } + + #[test] + fn prerelease_version() { + let path = build_local_wasm_path(Path::new("target"), &ident("foo"), Some("1.0.0-rc.1")); + assert_eq!(path, Path::new("target/foo_1_0_0-rc_1.wasm")); + } +} diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index f38cc01..021967e 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -3,483 +3,10 @@ //! the client types generated from the deployed contract's own wasm. extern crate proc_macro; -use proc_macro::TokenStream; -use std::{ - env, - path::{Path, PathBuf}, - process::Command, -}; +mod asset; +mod contract; +mod contract_client; -/// Path to the compiling crate's `Cargo.toml`. -fn manifest() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) - .join("Cargo.toml") -} - -/// Rust module identifier from a (possibly channel-prefixed) registry name: -/// final `/`-segment with `-` replaced by `_`. -fn mod_name_from(name_part: &str) -> String { - name_part - .rsplit('/') - .next() - .unwrap_or(name_part) - .replace('-', "_") -} - -/// A name whose derived module identifier is empty (e.g. `""`, `"foo/"`) cannot -/// form a valid Rust identifier. Reject it up front so the macro emits a -/// `compile_error!` instead of panicking inside `Ident::new`. -fn check_mod_name(mod_name: &str) -> Result<(), String> { - if mod_name.is_empty() { - Err( - "import_contract! needs a contract name whose module identifier is non-empty \ - (got an empty name, or one like \"foo/\")" - .to_string(), - ) - } else { - Ok(()) - } -} - -/// A deployed contract has no version — only a wasm does. Reject the `@version` -/// syntax `import_contract_client!` accepts, pointing the caller at the plain form. -fn reject_version(raw: &str) -> Result<(), String> { - if raw.contains('@') { - 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)`." - )) - } else { - Ok(()) - } -} - -/// Env var a caller can set to bypass the network: -/// `STELLAR_CONTRACT_ID_`, NAME = uppercased module name with any -/// non-alphanumeric replaced by `_`. -fn env_var_name(mod_name: &str) -> String { - let sanitized: String = mod_name - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_uppercase() - } else { - '_' - } - }) - .collect(); - format!("STELLAR_CONTRACT_ID_{sanitized}") -} - -/// 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:?}")) -} - -/// `/.id` — cached deployed address. Keyed by name only: -/// a deployed instance's address is version-independent. -fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { - target_dir.join(mod_name).with_extension("id") -} - -/// `/.wasm` — the deployed contract's wasm, fetched by -/// address, that `contractimport!` reads to generate the client types. -fn cache_wasm_path(target_dir: &Path, mod_name: &str) -> PathBuf { - target_dir.join(mod_name).with_extension("wasm") -} - -/// Resolve the deployed address. Precedence, first hit wins: -/// 1. `STELLAR_CONTRACT_ID_` env override — explicit, no flag check. -/// 2. `STELLAR_NO_REGISTRY=1` — offline: the `.id` cache, no flag check. -/// 3. otherwise online — `fetch` (which also fails if the contract is flagged) -/// and refresh the cache. The cache is deliberately NOT consulted online, -/// so a contract flagged after the first build cannot slip through a stale -/// `.id`. All IO is injected so precedence is unit-testable offline. -fn resolve_address( - env_lookup: impl Fn(&str) -> Option, - env_var: &str, - cache: Option, - no_registry: bool, - fetch: impl FnOnce() -> Result, -) -> Result { - if let Some(v) = env_lookup(env_var) { - return validate_contract_id(&v); - } - if no_registry { - return match cache { - Some(c) => validate_contract_id(&c), - None => Err(format!( - "STELLAR_NO_REGISTRY=1 but no cached contract id. Set {env_var}, or build \ - online once (which caches it), then rebuild offline." - )), - }; - } - validate_contract_id(&fetch()?) -} - -/// Shell out to the `stellar` CLI to look up a deployed contract's id by name, -/// failing if it is flagged as compromised (`--reject-flagged`). Network -/// selection is delegated to the CLI's own config (`STELLAR_NETWORK`). -fn fetch_contract_id(lookup_name: &str) -> Result { - let out = Command::new("stellar") - .args([ - "registry", - "fetch-contract-id", - lookup_name, - "--reject-flagged", - ]) - .output() - .map_err(|e| { - format!( - "failed to run `stellar registry fetch-contract-id`: {e}. \ - Install it with `cargo install stellar-registry-cli` and try again." - ) - })?; - if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - } else { - Err(format!( - "Could not resolve `{lookup_name}`. It may not be registered, may be flagged as \ - compromised, or the network may be wrong (https://stellar.rgstry.xyz). Run \ - `stellar registry fetch-contract-id {lookup_name}` yourself, or set \ - STELLAR_NO_REGISTRY=1 with a cached id to skip the registry lookup.\n{}", - String::from_utf8_lossy(&out.stderr) - )) - } -} - -/// 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) - )) - } -} - -use proc_macro2::Span; -use quote::quote; -use syn::{ - Expr, Ident, LitStr, Token, - parse::{Parse, ParseStream}, - parse_macro_input, -}; - -/// `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_raw: String, - name_span: Span, -} - -impl Parse for Input { - fn parse(input: ParseStream) -> syn::Result { - let env: Expr = input.parse()?; - input.parse::()?; - let name_span = input.span(); - let name_raw = if input.peek(LitStr) { - input.parse::()?.value() - } else { - input.parse::()?.to_string() - }; - Ok(Self { - env, - name_raw, - name_span, - }) - } -} - -/// Emit a block expression: generate the client types from the deployed -/// contract's own wasm, then construct the client bound to the baked address. -fn expand( - env: &Expr, - mod_ident: &Ident, - wasm_path: &str, - address: &str, -) -> proc_macro2::TokenStream { - quote! { - { - mod #mod_ident { - use super::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), - ) - } - } -} - -/// 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(/* ... */); -/// ``` -/// -/// `name` is a bare ident or string literal, optionally channel-prefixed -/// (`import_contract!(env, "unverified/our_dao")`). A deployed contract has no -/// version, so **no `@version` is accepted**. The client types are generated -/// from the deployed contract's *own* on-chain wasm, so a contract whose wasm -/// was never published to the registry still works. -/// -/// Resolved at build time: -/// - **address** — `STELLAR_CONTRACT_ID_` env override → (offline only) -/// `target/stellar//.id` cache → `stellar registry -/// fetch-contract-id`. The online path **fails compilation if the contract is -/// flagged as compromised** in the registry. -/// - **wasm** — `stellar contract fetch --id
`, cached beside the id. -/// -/// `STELLAR_NO_REGISTRY=1` forbids the network calls (requires a cached id + -/// wasm, and skips the flag check). Because a real on-chain address is baked in, -/// use this in real / integration builds; if the named contract is redeployed or -/// upgraded, clear the cache (`cargo clean`) and rebuild. -#[proc_macro] -pub fn import_contract(input: TokenStream) -> TokenStream { - let Input { - env, - name_raw, - name_span, - } = parse_macro_input!(input as Input); - - let err = |msg: String| -> TokenStream { - syn::Error::new(name_span, msg).to_compile_error().into() - }; - - if let Err(msg) = reject_version(&name_raw) { - return err(msg); - } - let mod_name = mod_name_from(&name_raw); - if let Err(msg) = check_mod_name(&mod_name) { - return err(msg); - } - let mod_ident = Ident::new(&mod_name, name_span); - let evar = env_var_name(&mod_name); - - let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); - let target_dir = match stellar_build::get_target_dir(&manifest()) { - Ok(dir) => dir, - Err(e) => return err(format!("could not determine the cargo target dir: {e}")), - }; - let id_path = cache_id_path(&target_dir, &mod_name); - let wasm_path = cache_wasm_path(&target_dir, &mod_name); - let cache = std::fs::read_to_string(&id_path).ok(); - - // 1. Resolve the deployed address (and, online, enforce the flag check). - let address = match resolve_address( - |k| env::var(k).ok(), - &evar, - cache, - no_registry, - || { - let addr = validate_contract_id(&fetch_contract_id(&name_raw)?)?; - let _ = std::fs::write(&id_path, &addr); - Ok(addr) - }, - ) { - Ok(a) => a, - Err(msg) => return err(msg), - }; - - // 2. Ensure the deployed contract's wasm is on disk for `contractimport!`. - if !wasm_path.exists() { - if no_registry { - return err(format!( - "STELLAR_NO_REGISTRY=1 but no cached wasm at {}. Build online once (which \ - fetches it) then rebuild offline.", - wasm_path.display() - )); - } - if let Err(msg) = fetch_wasm(&address, &wasm_path) { - return err(msg); - } - } - - // 3. Generate the client from that wasm and bind it to the address. - expand(&env, &mod_ident, &wasm_path.to_string_lossy(), &address).into() -} - -#[cfg(test)] -mod helpers { - use super::*; - use std::path::Path; - - // A real, valid contract strkey (from soroban-sdk docs). - const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; - - #[test] - fn mod_name_strips_prefix_and_hyphens() { - assert_eq!( - mod_name_from("unverified/registry_tansu_manager"), - "registry_tansu_manager" - ); - assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); - assert_eq!(mod_name_from("a/b/c"), "c"); - assert_eq!(mod_name_from("registry"), "registry"); - } - - #[test] - fn reject_version_rejects_at() { - assert!(reject_version("our_dao").is_ok()); - assert!(reject_version("unverified/our_dao").is_ok()); - assert!(reject_version("our_dao@v1.0.0").is_err()); - assert!(reject_version("our_dao@1.0.0").is_err()); - } - - #[test] - fn env_var_name_uppercases_and_sanitizes() { - assert_eq!( - env_var_name("registry_tansu_manager"), - "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER" - ); - assert_eq!( - env_var_name("guess_the_number"), - "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER" - ); - } - - #[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_target_siblings() { - assert_eq!( - cache_id_path(Path::new("target"), "our_dao"), - Path::new("target/our_dao.id") - ); - assert_eq!( - cache_wasm_path(Path::new("target"), "our_dao"), - Path::new("target/our_dao.wasm") - ); - } - - #[test] - fn check_mod_name_rejects_empty_identifiers() { - assert!(check_mod_name("our_dao").is_ok()); - assert!(check_mod_name("").is_err()); - assert!(check_mod_name(&mod_name_from("foo/")).is_err()); - } -} - -#[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 env_override_wins() { - let got = resolve_address( - |k| (k == "STELLAR_CONTRACT_ID_FOO").then(|| A.to_string()), - "STELLAR_CONTRACT_ID_FOO", - Some(B.to_string()), - false, - no_fetch, - ); - assert_eq!(got.unwrap(), A); - } - - #[test] - fn offline_uses_cache() { - let got = resolve_address(|_| None, "X", Some(B.to_string()), true, no_fetch); - assert_eq!(got.unwrap(), B); - } - - #[test] - fn offline_errors_without_env_or_cache() { - let got = resolve_address(|_| None, "X", None, true, no_fetch); - assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); - } - - #[test] - fn online_fetches_and_ignores_stale_cache() { - // A cached id must NOT short-circuit the online fetch (+ flag check). - let got = resolve_address(|_| None, "X", Some(B.to_string()), false, || Ok(A.to_string())); - assert_eq!(got.unwrap(), A); - } -} - -#[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 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/our_dao.wasm", A).to_string(); - assert!( - out.contains("contractimport"), - "generates types from the wasm: {out}" - ); - assert!( - !out.contains("import_contract_client"), - "does NOT delegate to import_contract_client!: {out}" - ); - assert!( - out.contains("our_dao.wasm"), - "references the fetched wasm: {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}"); - } -} +pub use asset::import_asset; +pub use contract::import_contract; +pub use contract_client::import_contract_client; From face1fbd04dac0de745f01e68bf1d2c8f438b217 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 16 Jul 2026 13:22:04 +0200 Subject: [PATCH 11/31] feat: initial work --- Cargo.lock | 7 + Cargo.toml | 1 + crates/stellar-registry-build/Cargo.toml | 5 + crates/stellar-registry-build/src/contract.rs | 6 +- crates/stellar-registry-build/src/lib.rs | 4 +- .../src/macro_plus/mod.rs | 2 + .../src/macro_plus/wrapper.rs | 12 + crates/stellar-registry-build/src/name.rs | 5 + .../src/name/prefixed.rs | 56 +++++ .../src/name/versioned.rs | 43 ++++ crates/stellar-registry-build/src/registry.rs | 13 +- crates/stellar-registry-macro/Cargo.toml | 5 + crates/stellar-registry-macro/src/asset.rs | 210 ++++++++++++++++++ crates/stellar-registry-macro/src/contract.rs | 27 +-- .../src/contract_client.rs | 13 +- crates/stellar-registry-macro/src/lib.rs | 52 ++++- crates/stellar-registry-macro/src/util.rs | 21 ++ 17 files changed, 449 insertions(+), 33 deletions(-) create mode 100644 crates/stellar-registry-build/src/macro_plus/mod.rs create mode 100644 crates/stellar-registry-build/src/macro_plus/wrapper.rs create mode 100644 crates/stellar-registry-build/src/name.rs create mode 100644 crates/stellar-registry-build/src/name/prefixed.rs create mode 100644 crates/stellar-registry-build/src/name/versioned.rs create mode 100644 crates/stellar-registry-macro/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index a8aee78..dd5148a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4262,6 +4262,8 @@ dependencies = [ "ed25519-dalek", "expect-test", "heck 0.5.0", + "proc-macro2", + "semver", "sha2 0.10.9", "shlex", "soroban-cli", @@ -4269,6 +4271,7 @@ dependencies = [ "stellar-build", "stellar-rpc-client", "stellar-strkey 0.0.16", + "syn 2.0.117", "thiserror 2.0.18", "tokio", ] @@ -4307,8 +4310,12 @@ version = "0.0.1" dependencies = [ "proc-macro2", "quote", + "regex", + "sha2 0.10.9", "stellar-build", + "stellar-registry-build", "stellar-strkey 0.0.16", + "stellar-xdr 27.0.0", "syn 2.0.117", ] diff --git a/Cargo.toml b/Cargo.toml index e5cee29..e7320b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/stellar-registry/cli" stellar-registry = { path = "crates/stellar-registry" } stellar-registry-test = { path = "crates/stellar-registry-test" } stellar-registry-macro = { path = "crates/stellar-registry-macro" } +stellar-registry-build = { path = "crates/stellar-registry-build" } # Cross-repo deps from scaffold-stellar/cli (crates.io for published libs, # git for stellar-scaffold-test which is `publish = false`). diff --git a/crates/stellar-registry-build/Cargo.toml b/crates/stellar-registry-build/Cargo.toml index a5622ef..6a519e2 100644 --- a/crates/stellar-registry-build/Cargo.toml +++ b/crates/stellar-registry-build/Cargo.toml @@ -19,6 +19,8 @@ stellar-build = { workspace = true } soroban-spec-tools = { workspace = true } soroban-rpc = { workspace = true } stellar-strkey = { workspace = true } +syn = { workspace = true } + thiserror = "2.0.17" tokio = { version = "1", features = ["full"] } @@ -26,8 +28,11 @@ shlex = "1.1.0" heck = "0.5.0" ed25519-dalek = "2.2.0" sha2 = { workspace = true } +proc-macro2 = { workspace = true, features = ["proc-macro"] } + dotenvy = "0.15.7" +semver = { version = "1.0.28", features = ["serde"] } # soroban-rpc = "=20.3.3" [dev-dependencies] diff --git a/crates/stellar-registry-build/src/contract.rs b/crates/stellar-registry-build/src/contract.rs index dd00879..ba2db7b 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,7 @@ pub enum ContractId { Resolved(stellar_strkey::Contract), Unresolved(stellar_cli::config::UnresolvedContract), PreHash(PreHashContractID), - FromRegistry(PrefixedName), + FromRegistry(name::Prefixed), } impl ContractId { @@ -105,7 +105,7 @@ impl ContractId { ContractId::PreHash(pre_hash_contract_id) => { pre_hash_contract_id.id(&network_passphrase.parse()?) } - ContractId::FromRegistry(PrefixedName { channel, name }) => { + ContractId::FromRegistry(name::Prefixed { channel, name }) => { Registry::new(config, channel.as_deref()) .await? .fetch_contract_id(name) diff --git a/crates/stellar-registry-build/src/lib.rs b/crates/stellar-registry-build/src/lib.rs index 3ac574e..ee1e70e 100644 --- a/crates/stellar-registry-build/src/lib.rs +++ b/crates/stellar-registry-build/src/lib.rs @@ -1,6 +1,8 @@ pub mod contract; pub mod error; -pub mod named_registry; +pub mod macro_plus; +pub mod name; + pub mod registry; pub use error::Error; diff --git a/crates/stellar-registry-build/src/macro_plus/mod.rs b/crates/stellar-registry-build/src/macro_plus/mod.rs new file mode 100644 index 0000000..20e479f --- /dev/null +++ b/crates/stellar-registry-build/src/macro_plus/mod.rs @@ -0,0 +1,2 @@ +pub mod wrapper; +pub use wrapper::*; diff --git a/crates/stellar-registry-build/src/macro_plus/wrapper.rs b/crates/stellar-registry-build/src/macro_plus/wrapper.rs new file mode 100644 index 0000000..22c34fb --- /dev/null +++ b/crates/stellar-registry-build/src/macro_plus/wrapper.rs @@ -0,0 +1,12 @@ +extern crate proc_macro; + +pub trait ProcMacroWrapper { + fn to_token_stream(&self) -> proc_macro::TokenStream; +} + +impl ProcMacroWrapper for syn::Result { + fn to_token_stream(&self) -> proc_macro::TokenStream { + self.clone() + .map_or_else(|e| e.to_compile_error().into(), |inner| inner.into()) + } +} diff --git a/crates/stellar-registry-build/src/name.rs b/crates/stellar-registry-build/src/name.rs new file mode 100644 index 0000000..223645c --- /dev/null +++ b/crates/stellar-registry-build/src/name.rs @@ -0,0 +1,5 @@ +pub mod prefixed; +pub mod versioned; + +pub use prefixed::*; +pub use versioned::*; diff --git a/crates/stellar-registry-build/src/name/prefixed.rs b/crates/stellar-registry-build/src/name/prefixed.rs new file mode 100644 index 0000000..fd9a177 --- /dev/null +++ b/crates/stellar-registry-build/src/name/prefixed.rs @@ -0,0 +1,56 @@ +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 Prefixed { + pub channel: Option, + pub name: String, +} + +impl FromStr for Prefixed { + 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: Prefixed) -> Self { + Self::FromRegistry(value) + } +} + +impl Prefixed { + pub async fn registry(&self, config: &config::Args) -> Result { + Registry::from_named_registry(config, self).await + } +} + +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() + ) + } +} diff --git a/crates/stellar-registry-build/src/name/versioned.rs b/crates/stellar-registry-build/src/name/versioned.rs new file mode 100644 index 0000000..cc37d4d --- /dev/null +++ b/crates/stellar-registry-build/src/name/versioned.rs @@ -0,0 +1,43 @@ +use std::{convert::Infallible, fmt::Display, str::FromStr}; + +use crate::name::Prefixed; + +#[derive(Clone, Debug)] +/// Help docs for special type +pub struct Versioned { + pub name: Prefixed, + pub version: Option, +} + +impl FromStr for Versioned { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + if let Some((name, version)) = s.split_once('@') { + Ok(Self { + name: name.parse()?, + version: version.parse().ok(), + }) + } else { + Ok(Self { + name: s.parse()?, + version: None, + }) + } + } +} + +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() + ) + } +} diff --git a/crates/stellar-registry-build/src/registry.rs b/crates/stellar-registry-build/src/registry.rs index 4863794..68c43a8 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 } + pub async fn new(config: &config::Args, name: Option<&str>) -> Result { let contract = Self::verified(config)?; Ok(if let Some(name) = name { @@ -57,7 +58,13 @@ impl Registry { use stellar_cli::xdr; let canonical: String = name .chars() - .map(|c| if c == '_' { '-' } else { c.to_ascii_lowercase() }) + .map(|c| { + if c == '_' { + '-' + } else { + c.to_ascii_lowercase() + } + }) .collect(); let key = xdr::ScVal::Vec(Some( vec![ diff --git a/crates/stellar-registry-macro/Cargo.toml b/crates/stellar-registry-macro/Cargo.toml index 888f8b6..91f4f62 100644 --- a/crates/stellar-registry-macro/Cargo.toml +++ b/crates/stellar-registry-macro/Cargo.toml @@ -14,7 +14,12 @@ proc-macro2 = { workspace = true } quote = { workspace = true } syn = { workspace = true } stellar-build = { workspace = true } +stellar-registry-build = { workspace = true } stellar-strkey = { workspace = true } +stellar-xdr = { workspace = true } +regex = "1.12.3" +sha2 = { workspace = true } + [lints] workspace = true diff --git a/crates/stellar-registry-macro/src/asset.rs b/crates/stellar-registry-macro/src/asset.rs index e69de29..9f723ad 100644 --- a/crates/stellar-registry-macro/src/asset.rs +++ b/crates/stellar-registry-macro/src/asset.rs @@ -0,0 +1,210 @@ +use proc_macro2::TokenStream; +use sha2::{Digest, Sha256}; + +use stellar_build::Network; +use stellar_xdr as xdr; +use xdr::WriteXdr; + +use quote::{format_ident, quote}; + +pub fn parse_asset(str: &str) -> Result<(xdr::Asset, String), xdr::Error> { + if str == "native" || str == "xlm" { + return Ok((xdr::Asset::Native, str.to_string())); + } + let split: Vec<&str> = str.splitn(2, ':').collect(); + assert!(split.len() == 2, "invalid asset \"{str}\""); + let code = split[0]; + let issuer: xdr::AccountId = split[1].parse()?; + let re = regex::Regex::new("^[[:alnum:]]{1,12}$").expect("regex failed"); + assert!(re.is_match(code), "invalid asset \"{str}\""); + let asset_code: xdr::AssetCode = code.parse()?; + 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(), + )) +} + +pub fn generate_asset_id( + asset: &str, + network: &Network, +) -> Result<(stellar_strkey::Contract, String), xdr::Error> { + let (asset, code) = parse_asset(asset).unwrap(); + let network_id = xdr::Hash(network.id()); + let preimage = xdr::HashIdPreimage::ContractId(xdr::HashIdPreimageContractId { + network_id, + contract_id_preimage: xdr::ContractIdPreimage::Asset(asset.clone()), + }); + let preimage_xdr = preimage.to_xdr(xdr::Limits::none())?; + Ok(( + stellar_strkey::Contract(Sha256::digest(preimage_xdr).into()), + code, + )) +} + +/// Generate the code to read the `STELLAR_NETWORK` environment variable +/// and call the `generate_asset_id` function +pub fn parse_literal(lit_str: &syn::LitStr, network: &Network) -> TokenStream { + let (contract_id, code) = generate_asset_id(&lit_str.value(), network).unwrap(); + // let contract_id = format_ident!("\"{contract_id}\""); + let contract_id = contract_id.to_string(); + let mod_name = format_ident!("{code}"); + quote! { + #[allow(non_upper_case_globals)] + 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 Stellar Asset Client for the asset which provides an admin 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"; + + // 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 = quote! { + #[allow (non_upper_case_globals)] + pub(crate) mod native { + 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, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC") + } + /// 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 Stellar Asset Client for the asset which provides an admin interface + pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) + } + } + }; + let generated = parse_literal(&lit, &Network::Testnet); + assert_eq!(generated.to_string(), expected.to_string()); + } + + #[test] + fn xlm_client() { + let lit: syn::LitStr = syn::parse_quote!("xlm"); + let expected = quote! { + #[allow (non_upper_case_globals)] + pub(crate) mod xlm { + 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, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC") + } + /// 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 Stellar Asset Client for the asset which provides an admin interface + pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) + } + + } + }; + let generated = parse_literal(&lit, &Network::Testnet); + assert_eq!(generated.to_string(), expected.to_string()); + } + + #[test] + fn usdc_client() { + let lit: syn::LitStr = + syn::parse_quote!("USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); + let expected = quote! { + #[allow (non_upper_case_globals)] + pub(crate) mod USDC { + 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, "CA2E53VHFZ6YSWQIEIPBXJQGT6VW3VKWWZO555XKRQXYJ63GEBJJGHY7") + } + /// 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 Stellar Asset Client for the asset which provides an admin interface + pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { + soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) + } + + } + }; + let generated = parse_literal(&lit, &Network::Testnet); + assert_eq!(generated.to_string(), expected.to_string()); + } +} diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index ff06342..91d20bb 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -177,29 +177,28 @@ fn fetch_wasm(address: &str, out_path: &Path) -> Result<(), String> { } } +enum Name { + Ident(Ident), + LitStr(LitStr), +} + /// `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_raw: String, - name_span: Span, + name: Name, } impl Parse for Input { fn parse(input: ParseStream) -> syn::Result { let env: Expr = input.parse()?; input.parse::()?; - let name_span = input.span(); - let name_raw = if input.peek(LitStr) { - input.parse::()?.value() + let name = if input.peek(LitStr) { + Name::LitStr(input.parse::()?) } else { - input.parse::()?.to_string() + Name::Ident(input.parse::()?) }; - Ok(Self { - env, - name_raw, - name_span, - }) + Ok(Self { env, name }) } } @@ -255,11 +254,7 @@ fn expand( /// upgraded, clear the cache (`cargo clean`) and rebuild. #[proc_macro] pub fn import_contract(input: TokenStream) -> TokenStream { - let Input { - env, - name_raw, - name_span, - } = parse_macro_input!(input as Input); + let Input { env, name } = parse_macro_input!(input as Input); let err = |msg: String| -> TokenStream { syn::Error::new(name_span, msg).to_compile_error().into() }; diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs index f162e53..029464c 100644 --- a/crates/stellar-registry-macro/src/contract_client.rs +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -4,7 +4,7 @@ use quote::quote; use std::env; use stellar_build::Network; use syn::parse::{Parse, ParseStream, Result}; -use syn::{Ident, LitStr, parse_macro_input}; +use syn::{Ident, LitStr}; pub(crate) fn manifest() -> std::path::PathBuf { std::path::PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) @@ -38,18 +38,17 @@ pub(crate) fn manifest() -> std::path::PathBuf { /// - If the input tokens cannot be parsed as a valid identifier or string literal /// - If the directory path cannot be canonicalized /// - If the canonical path cannot be converted to a string -#[proc_macro] -pub fn import_contract_client(wasm_binary: TokenStream) -> TokenStream { - let WasmBinary { mod_name, file } = parse_macro_input!(wasm_binary as WasmBinary); - quote! { +pub fn import_contract_client(wasm_binary: TokenStream) -> Result { + let WasmBinary { mod_name, file } = syn::parse::(wasm_binary)?; + + Ok(quote! { pub(crate) mod #mod_name { #![allow(clippy::ref_option, clippy::too_many_arguments)] use super::soroban_sdk; soroban_sdk::contractimport!(file = #file); } - } - .into() + }) } struct WasmBinary { diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index 021967e..c35c306 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -2,11 +2,57 @@ //! to a type-safe client already bound to its deployed on-chain address, with //! the client types generated from the deployed contract's own wasm. extern crate proc_macro; +use proc_macro::TokenStream; mod asset; mod contract; mod contract_client; +mod util; -pub use asset::import_asset; -pub use contract::import_contract; -pub use contract_client::import_contract_client; +use asset::import_asset; +use contract::import_contract; +use stellar_registry_build::macro_plus::*; + +/// Generates a contract Client for a given contract. +/// The name should match a published contract or a contract in your current workspace. +/// +/// # Usage +/// +/// ```ignore +/// // For simple names (workspace contracts or registry names without hyphens): +/// import_contract_client!(registry); +/// +/// // For hyphenated names or channel-prefixed registry paths: +/// import_contract_client!("unverified/guess-the-number"); +/// +/// // For specific versions, use quotes. `v` is optional: +/// import_contract_client!("registry@v1.0.0"); +/// ``` +/// +/// When using a string literal, the module name is derived from the contract +/// name with hyphens replaced by underscores (e.g., `guess_the_number`). +/// +/// # Panics +/// +/// This function may panic in the following situations: +/// - If `stellar_build::get_target_dir()` fails to retrieve the target directory +/// - If the input tokens cannot be parsed as a valid identifier +/// - If the input tokens cannot be parsed as a valid identifier or string literal +/// - If the directory path cannot be canonicalized +/// - If the canonical path cannot be converted to a string +#[proc_macro] +pub fn import_contract_client(wasm_binary: TokenStream) -> TokenStream { + contract_client::import_contract_client(wasm_binary).to_token_stream() +} + +/// Generates a contract Client for a given asset. +/// It is expected that the name of an asset, e.g. "native" or "USDC:G1...." +/// +/// # Panics +/// +#[proc_macro] +pub fn import_asset(input: TokenStream) -> TokenStream { + // Parse the input as a string literal + let input_str = syn::parse_macro_input!(input as syn::LitStr); + asset::parse_literal(&input_str, &Network::passphrase_from_env()).into() +} diff --git a/crates/stellar-registry-macro/src/util.rs b/crates/stellar-registry-macro/src/util.rs new file mode 100644 index 0000000..d074552 --- /dev/null +++ b/crates/stellar-registry-macro/src/util.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +/// Path to the compiling crate's `Cargo.toml`. +fn manifest() -> syn::Result { + Ok(PathBuf::from( + std::env::var("CARGO_MANIFEST_DIR") + .map_err(|_| "failed to find cargo manifest".into())?) + .join("Cargo.toml"), + ) +} + +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.clone() + .map_or_else(|e| e.to_compile_error().into(), |inner| inner.into()) + } +} From 0acb8aeb29aa036d773908f199c771e1886661af Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 16 Jul 2026 14:46:07 +0200 Subject: [PATCH 12/31] feat: consolidate registry macros This builds on the initial PrefixedName type but adds a Versioned type which makes checking for versions on wasm parsable. Also addressed other issues in PR review. --- CLAUDE.md | 6 +- Cargo.lock | 63 +- Cargo.toml | 7 +- README.md | 2 +- crates/stellar-registry-build/Cargo.toml | 39 +- crates/stellar-registry-build/src/contract.rs | 12 +- crates/stellar-registry-build/src/error.rs | 2 + crates/stellar-registry-build/src/lib.rs | 13 +- .../src/macro_plus/mod.rs | 2 - .../src/macro_plus/wrapper.rs | 12 - crates/stellar-registry-build/src/name.rs | 62 +- .../src/name/prefixed.rs | 164 ++++- .../src/name/versioned.rs | 124 +++- .../src/named_registry.rs | 56 -- crates/stellar-registry-build/src/registry.rs | 42 +- crates/stellar-registry-cli/Cargo.toml | 2 +- .../src/commands/create_alias.rs | 30 +- .../src/commands/current_version.rs | 6 +- .../src/commands/deploy.rs | 22 +- .../src/commands/deploy_unnamed.rs | 8 +- .../src/commands/download.rs | 6 +- .../src/commands/fetch_contract_id.rs | 35 +- .../src/commands/fetch_hash.rs | 6 +- .../src/commands/publish.rs | 18 +- .../src/commands/publish_hash.rs | 10 +- .../src/commands/register_contract.rs | 10 +- .../src/commands/rename_contract.rs | 10 +- .../src/commands/update_contract_address.rs | 10 +- .../src/commands/update_contract_owner.rs | 10 +- .../src/commands/upgrade.rs | 10 +- crates/stellar-registry-macro/Cargo.toml | 4 +- crates/stellar-registry-macro/src/asset.rs | 204 +++--- crates/stellar-registry-macro/src/contract.rs | 515 +++++++------- .../src/contract_client.rs | 442 +++++------- crates/stellar-registry-macro/src/lib.rs | 101 ++- crates/stellar-registry-macro/src/util.rs | 195 +++++- crates/stellar-registry/Cargo.toml | 1 - crates/stellar-registry/src/lib.rs | 3 +- .../plans/2026-07-02-import-contract-macro.md | 632 ------------------ ...2026-07-02-import-contract-macro-design.md | 213 ------ 40 files changed, 1285 insertions(+), 1824 deletions(-) delete mode 100644 crates/stellar-registry-build/src/macro_plus/mod.rs delete mode 100644 crates/stellar-registry-build/src/macro_plus/wrapper.rs delete mode 100644 crates/stellar-registry-build/src/named_registry.rs delete mode 100644 docs/superpowers/plans/2026-07-02-import-contract-macro.md delete mode 100644 docs/superpowers/specs/2026-07-02-import-contract-macro-design.md diff --git a/CLAUDE.md b/CLAUDE.md index a53e45a..0bfac6e 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,7 @@ 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` | Re-exports the `import_contract!`, `import_contract_client!`, and `import_asset!` macros from `stellar-registry-macro` (published to crates.io; dev-dependency of `stellar-registry/contracts`) | ### CLI Command Flow @@ -59,7 +59,7 @@ 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-build` — published from `stellar-scaffold/cli` - `stellar-scaffold-test` — pulled via git from `stellar-scaffold/cli` (it is `publish = false`); used in tests only 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 dd5148a..2fb811b 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", ] @@ -4251,29 +4251,20 @@ name = "stellar-registry" version = "0.0.11" dependencies = [ "stellar-registry-macro", - "stellar-scaffold-macro", ] [[package]] name = "stellar-registry-build" -version = "0.0.9" +version = "0.0.10" dependencies = [ - "dotenvy", - "ed25519-dalek", "expect-test", - "heck 0.5.0", - "proc-macro2", "semver", "sha2 0.10.9", - "shlex", "soroban-cli", - "soroban-spec-tools", "stellar-build", "stellar-rpc-client", "stellar-strkey 0.0.16", - "syn 2.0.117", "thiserror 2.0.18", - "tokio", ] [[package]] @@ -4310,12 +4301,11 @@ version = "0.0.1" dependencies = [ "proc-macro2", "quote", - "regex", "sha2 0.10.9", "stellar-build", "stellar-registry-build", "stellar-strkey 0.0.16", - "stellar-xdr 27.0.0", + "stellar-xdr", "syn 2.0.117", ] @@ -4354,29 +4344,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" @@ -4403,21 +4377,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 e7320b9..c36c370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,13 +10,14 @@ 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" } -stellar-registry-build = { path = "crates/stellar-registry-build" } +stellar-registry-macro = { path = "crates/stellar-registry-macro", version = "0.0.1" } +# default-features = false so the proc-macro crate gets only the light `name` +# module; network-facing consumers opt back in with features = ["cli"]. +stellar-registry-build = { path = "crates/stellar-registry-build", version = "0.0.10", default-features = false } # Cross-repo deps from scaffold-stellar/cli (crates.io for published libs, # git for stellar-scaffold-test which is `publish = false`). 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" } diff --git a/README.md b/README.md index 18d7190..67424ea 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ 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`](./crates/stellar-registry) | The `import_contract!`, `import_contract_client!`, and `import_asset!` macros | ## What is the Contract Registry? diff --git a/crates/stellar-registry-build/Cargo.toml b/crates/stellar-registry-build/Cargo.toml index 6a519e2..9f93f37 100644 --- a/crates/stellar-registry-build/Cargo.toml +++ b/crates/stellar-registry-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stellar-registry-build" -version = "0.0.9" +version = "0.0.10" edition = "2024" description = "A library using the registry at build time" license = "Apache-2.0" @@ -12,28 +12,29 @@ crate-type = ["rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[dependencies] -stellar-cli = { workspace = true, default-features = false, features = [] } -stellar-build = { workspace = true } +[features] +default = ["cli"] +# Network-facing registry access (contract, registry, error modules). Off, the +# crate is just the dependency-light `name` module — what proc-macro consumers +# want, so they don't drag the stellar-cli stack into every contract build. +cli = [ + "dep:sha2", + "dep:soroban-rpc", + "dep:stellar-build", + "dep:stellar-cli", + "dep:stellar-strkey", +] -soroban-spec-tools = { workspace = true } -soroban-rpc = { workspace = true } -stellar-strkey = { workspace = true } -syn = { workspace = true } +[dependencies] +stellar-cli = { workspace = true, optional = true, default-features = false, features = [] } +stellar-build = { workspace = true, optional = true } +soroban-rpc = { workspace = true, optional = true } +stellar-strkey = { workspace = true, optional = true } +sha2 = { workspace = true, optional = 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 } -proc-macro2 = { workspace = true, features = ["proc-macro"] } - - -dotenvy = "0.15.7" -semver = { version = "1.0.28", features = ["serde"] } -# soroban-rpc = "=20.3.3" +semver = "1.0.28" [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 ba2db7b..6c03725 100644 --- a/crates/stellar-registry-build/src/contract.rs +++ b/crates/stellar-registry-build/src/contract.rs @@ -91,6 +91,12 @@ pub enum ContractId { FromRegistry(name::Prefixed), } +impl From for ContractId { + fn from(value: name::Prefixed) -> Self { + Self::FromRegistry(value) + } +} + impl ContractId { pub async fn resolve_id( &self, @@ -105,10 +111,10 @@ impl ContractId { ContractId::PreHash(pre_hash_contract_id) => { pre_hash_contract_id.id(&network_passphrase.parse()?) } - ContractId::FromRegistry(name::Prefixed { 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 668cc2a..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)] diff --git a/crates/stellar-registry-build/src/lib.rs b/crates/stellar-registry-build/src/lib.rs index ee1e70e..6be7c0c 100644 --- a/crates/stellar-registry-build/src/lib.rs +++ b/crates/stellar-registry-build/src/lib.rs @@ -1,8 +1,17 @@ +//! Registry interaction at build time. +//! +//! The `name` module (typed registry names) is always available and +//! dependency-light so proc-macro crates can use it. Everything that talks to +//! the network — `contract`, `registry`, `error` — sits behind the default +//! `cli` feature, which pulls in the full stellar-cli stack. + +#[cfg(feature = "cli")] pub mod contract; +#[cfg(feature = "cli")] pub mod error; -pub mod macro_plus; pub mod name; - +#[cfg(feature = "cli")] pub mod registry; +#[cfg(feature = "cli")] pub use error::Error; diff --git a/crates/stellar-registry-build/src/macro_plus/mod.rs b/crates/stellar-registry-build/src/macro_plus/mod.rs deleted file mode 100644 index 20e479f..0000000 --- a/crates/stellar-registry-build/src/macro_plus/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod wrapper; -pub use wrapper::*; diff --git a/crates/stellar-registry-build/src/macro_plus/wrapper.rs b/crates/stellar-registry-build/src/macro_plus/wrapper.rs deleted file mode 100644 index 22c34fb..0000000 --- a/crates/stellar-registry-build/src/macro_plus/wrapper.rs +++ /dev/null @@ -1,12 +0,0 @@ -extern crate proc_macro; - -pub trait ProcMacroWrapper { - fn to_token_stream(&self) -> proc_macro::TokenStream; -} - -impl ProcMacroWrapper for syn::Result { - fn to_token_stream(&self) -> proc_macro::TokenStream { - self.clone() - .map_or_else(|e| e.to_compile_error().into(), |inner| inner.into()) - } -} diff --git a/crates/stellar-registry-build/src/name.rs b/crates/stellar-registry-build/src/name.rs index 223645c..4b44879 100644 --- a/crates/stellar-registry-build/src/name.rs +++ b/crates/stellar-registry-build/src/name.rs @@ -1,5 +1,63 @@ +//! Typed, validated registry names. +//! +//! Parsing is the only way to construct these types, so holding one is proof +//! the name is structurally valid ("parse, don't validate"): +//! +//! - [`Prefixed`] — `name` or `channel/name`, no version. +//! - [`Versioned`] — a [`Prefixed`] plus an optional `@version` suffix. + pub mod prefixed; pub mod versioned; -pub use prefixed::*; -pub use versioned::*; +pub use prefixed::Prefixed; +pub use versioned::Versioned; + +#[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, + }, +} + +/// 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.chars() + .map(|c| { + if c == '_' { + '-' + } else { + c.to_ascii_lowercase() + } + }) + .collect() +} + +#[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-build/src/name/prefixed.rs b/crates/stellar-registry-build/src/name/prefixed.rs index fd9a177..313bef8 100644 --- a/crates/stellar-registry-build/src/name/prefixed.rs +++ b/crates/stellar-registry-build/src/name/prefixed.rs @@ -1,43 +1,76 @@ -use std::{convert::Infallible, fmt::Display, str::FromStr}; +use std::{fmt::Display, str::FromStr}; -use stellar_cli::config; +use super::Error; -use crate::{Error, contract::ContractId, registry::Registry}; - -#[derive(Clone, Debug)] -/// Help docs for special type +/// 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 { - pub channel: Option, - pub name: String, + channel: Option, + name: String, } impl FromStr for Prefixed { - type Err = Infallible; + type Err = Error; 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(), - }) + 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 From for ContractId { - fn from(value: Prefixed) -> Self { - Self::FromRegistry(value) +impl Prefixed { + /// The bare contract name, without the channel prefix. + #[must_use] + pub fn name(&self) -> &str { + &self.name } -} -impl Prefixed { - pub async fn registry(&self, config: &config::Args) -> Result { - Registry::from_named_registry(config, self).await + /// 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: `-` → `_`. + #[must_use] + pub fn mod_name(&self) -> String { + self.name.replace('-', "_") + } + + /// Canonical on-chain form of the bare name (see [`super::canonicalize`]). + #[must_use] + pub fn canonical_name(&self) -> String { + super::canonicalize(&self.name) } } @@ -54,3 +87,82 @@ impl Display for Prefixed { ) } } + +#[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-build/src/name/versioned.rs b/crates/stellar-registry-build/src/name/versioned.rs index cc37d4d..6beec98 100644 --- a/crates/stellar-registry-build/src/name/versioned.rs +++ b/crates/stellar-registry-build/src/name/versioned.rs @@ -1,32 +1,59 @@ -use std::{convert::Infallible, fmt::Display, str::FromStr}; +use std::{fmt::Display, str::FromStr}; -use crate::name::Prefixed; +use super::{Error, Prefixed}; -#[derive(Clone, Debug)] -/// Help docs for special type +/// 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 { - pub name: Prefixed, - pub version: Option, + name: Prefixed, + version: Option, } impl FromStr for Versioned { - type Err = Infallible; + type Err = Error; fn from_str(s: &str) -> Result { - if let Some((name, version)) = s.split_once('@') { - Ok(Self { - name: name.parse()?, - version: version.parse().ok(), - }) - } else { - Ok(Self { + 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; @@ -41,3 +68,70 @@ impl Display for Versioned { ) } } + +#[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-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 68c43a8..be5d41c 100644 --- a/crates/stellar-registry-build/src/registry.rs +++ b/crates/stellar-registry-build/src/registry.rs @@ -8,12 +8,19 @@ use crate::{ pub struct Registry(Contract); +impl name::Prefixed { + /// Resolve the (sub)registry this name's channel points at. + pub async fn registry(&self, config: &config::Args) -> Result { + Registry::from_named_registry(config, self).await + } +} + impl Registry { pub async fn from_named_registry( config: &config::Args, 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 { @@ -29,7 +36,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 @@ -39,8 +62,12 @@ 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(), )) } @@ -56,16 +83,7 @@ impl Registry { /// `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: String = name - .chars() - .map(|c| { - if c == '_' { - '-' - } else { - c.to_ascii_lowercase() - } - }) - .collect(); + let canonical = name::canonicalize(name); let key = xdr::ScVal::Vec(Some( vec![ xdr::ScVal::Symbol(xdr::ScSymbol("CR".try_into()?)), diff --git a/crates/stellar-registry-cli/Cargo.toml b/crates/stellar-registry-cli/Cargo.toml index 1a8cac9..84a1736 100644 --- a/crates/stellar-registry-cli/Cargo.toml +++ b/crates/stellar-registry-cli/Cargo.toml @@ -32,7 +32,7 @@ clap = { workspace = true, features = [ "string", ] } stellar-cli = { workspace = true, default-features = false, features = [] } -stellar-registry-build = { path = "../stellar-registry-build", version = "0.0.9" } +stellar-registry-build = { path = "../stellar-registry-build", version = "0.0.10" } soroban-spec-tools = { workspace = true } diff --git a/crates/stellar-registry-cli/src/commands/create_alias.rs b/crates/stellar-registry-cli/src/commands/create_alias.rs index 7de23c9..302a8b6 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; 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..fcfd42d 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; 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..f35bdbb 100644 --- a/crates/stellar-registry-cli/src/commands/deploy.rs +++ b/crates/stellar-registry-cli/src/commands/deploy.rs @@ -12,7 +12,7 @@ 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, registry::Registry}; use crate::commands::global; @@ -23,11 +23,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 +90,7 @@ impl Cmd { Ok(contract_id) => { println!( "Contract {} deployed successfully to {contract_id}", - self.contract_name.name + self.contract_name.name() ); Ok(()) } @@ -105,7 +105,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 +143,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 +161,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..589beff 100644 --- a/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs +++ b/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs @@ -12,7 +12,7 @@ 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, registry::Registry}; use crate::commands::global; @@ -23,7 +23,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 +89,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 +149,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..2fe0d52 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; 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 459c6ac..ba6fd5b 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; use stellar_strkey::Contract; use crate::commands::global; @@ -9,13 +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, - /// Fail (non-zero exit) if the contract is flagged as compromised in the - /// registry. Used by `import_contract!` to refuse importing a flagged - /// contract at build time. + /// 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 reject_flagged: bool, + pub force: bool, #[command(flatten)] pub config: global::Args, @@ -29,7 +28,9 @@ 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")] + #[error( + "contract `{0}` is flagged as compromised in the registry; pass --force to fetch its id anyway" + )] ContractFlagged(String), } @@ -42,14 +43,20 @@ impl Cmd { pub async fn fetch_contract_id(&self) -> Result { let registry = self.contract_name.registry(&self.config).await?; - if self.reject_flagged - && registry - .is_contract_flagged(&self.contract_name.name) - .await? - { - return Err(Error::ContractFlagged(self.contract_name.to_string())); + if self.force { + return Ok(registry + .fetch_contract_id_unchecked(self.contract_name.name()) + .await?); } - Ok(registry.fetch_contract_id(&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..4986dd8 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; 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..cd941d2 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; 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 index 91f4f62..52d0441 100644 --- a/crates/stellar-registry-macro/Cargo.toml +++ b/crates/stellar-registry-macro/Cargo.toml @@ -14,12 +14,12 @@ proc-macro2 = { workspace = true } quote = { workspace = true } syn = { workspace = true } stellar-build = { workspace = true } +# name types only — the workspace pins default-features = false so this +# proc-macro never drags the stellar-cli stack into consumer contract builds. stellar-registry-build = { workspace = true } stellar-strkey = { workspace = true } stellar-xdr = { workspace = true } -regex = "1.12.3" sha2 = { workspace = true } - [lints] workspace = true diff --git a/crates/stellar-registry-macro/src/asset.rs b/crates/stellar-registry-macro/src/asset.rs index 9f723ad..84afa48 100644 --- a/crates/stellar-registry-macro/src/asset.rs +++ b/crates/stellar-registry-macro/src/asset.rs @@ -5,19 +5,36 @@ use stellar_build::Network; use stellar_xdr as xdr; use xdr::WriteXdr; -use quote::{format_ident, quote}; +use quote::quote; +use syn::LitStr; -pub fn parse_asset(str: &str) -> Result<(xdr::Asset, String), xdr::Error> { - if str == "native" || str == "xlm" { - return Ok((xdr::Asset::Native, str.to_string())); +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 split: Vec<&str> = str.splitn(2, ':').collect(); - assert!(split.len() == 2, "invalid asset \"{str}\""); - let code = split[0]; - let issuer: xdr::AccountId = split[1].parse()?; - let re = regex::Regex::new("^[[:alnum:]]{1,12}$").expect("regex failed"); - assert!(re.is_match(code), "invalid asset \"{str}\""); - let asset_code: xdr::AssetCode = code.parse()?; + 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) => { @@ -31,32 +48,40 @@ pub fn parse_asset(str: &str) -> Result<(xdr::Asset, String), xdr::Error> { )) } -pub fn generate_asset_id( +/// 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), xdr::Error> { - let (asset, code) = parse_asset(asset).unwrap(); +) -> 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.clone()), + contract_id_preimage: xdr::ContractIdPreimage::Asset(asset), }); - let preimage_xdr = preimage.to_xdr(xdr::Limits::none())?; + 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 the code to read the `STELLAR_NETWORK` environment variable -/// and call the `generate_asset_id` function -pub fn parse_literal(lit_str: &syn::LitStr, network: &Network) -> TokenStream { - let (contract_id, code) = generate_asset_id(&lit_str.value(), network).unwrap(); - // let contract_id = format_ident!("\"{contract_id}\""); - let contract_id = contract_id.to_string(); - let mod_name = format_ident!("{code}"); - quote! { - #[allow(non_upper_case_globals)] +/// 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 @@ -67,12 +92,12 @@ pub fn parse_literal(lit_str: &syn::LitStr, network: &Network) -> TokenStream { 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 Stellar Asset Client for the asset which provides an admin interface + /// 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)] @@ -88,6 +113,28 @@ mod test { 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() { @@ -133,51 +180,22 @@ mod test { #[test] fn native_client() { let lit: syn::LitStr = syn::parse_quote!("native"); - let expected = quote! { - #[allow (non_upper_case_globals)] - pub(crate) mod native { - 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, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC") - } - /// 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 Stellar Asset Client for the asset which provides an admin interface - pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { - soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) - } - } - }; - let generated = parse_literal(&lit, &Network::Testnet); + 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 = quote! { - #[allow (non_upper_case_globals)] - pub(crate) mod xlm { - 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, "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC") - } - /// 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 Stellar Asset Client for the asset which provides an admin interface - pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { - soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) - } - - } - }; - let generated = parse_literal(&lit, &Network::Testnet); + let expected = expected_module( + "xlm", + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + let generated = parse_literal(&lit, &Network::Testnet).unwrap(); assert_eq!(generated.to_string(), expected.to_string()); } @@ -185,26 +203,40 @@ mod test { fn usdc_client() { let lit: syn::LitStr = syn::parse_quote!("USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); - let expected = quote! { - #[allow (non_upper_case_globals)] - pub(crate) mod USDC { - 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, "CA2E53VHFZ6YSWQIEIPBXJQGT6VW3VKWWZO555XKRQXYJ63GEBJJGHY7") - } - /// 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 Stellar Asset Client for the asset which provides an admin interface - pub fn token_client<'a>(env: &soroban_sdk::Env) -> soroban_sdk::token::TokenClient<'a> { - soroban_sdk::token::TokenClient::new(&env, &contract_id(env)) - } - - } - }; - let generated = parse_literal(&lit, &Network::Testnet); + 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 index 91d20bb..e9714d2 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -1,79 +1,35 @@ -use proc_macro::TokenStream; use std::{ env, path::{Path, PathBuf}, process::Command, }; -use proc_macro2::Span; use quote::quote; use syn::{ - Expr, Ident, LitStr, Token, + Expr, Ident, Token, parse::{Parse, ParseStream}, - parse_macro_input, }; -/// Path to the compiling crate's `Cargo.toml`. -fn manifest() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) - .join("Cargo.toml") -} +use stellar_registry_build::name::Prefixed; -/// Rust module identifier from a (possibly channel-prefixed) registry name: -/// final `/`-segment with `-` replaced by `_`. -fn mod_name_from(name_part: &str) -> String { - name_part - .rsplit('/') - .next() - .unwrap_or(name_part) - .replace('-', "_") -} +use crate::util::{Name, explorer_url, manifest, mod_ident, network_name}; -/// A name whose derived module identifier is empty (e.g. `""`, `"foo/"`) cannot -/// form a valid Rust identifier. Reject it up front so the macro emits a -/// `compile_error!` instead of panicking inside `Ident::new`. -fn check_mod_name(mod_name: &str) -> Result<(), String> { - if mod_name.is_empty() { - Err( - "import_contract! needs a contract name whose module identifier is non-empty \ - (got an empty name, or one like \"foo/\")" - .to_string(), - ) - } else { - Ok(()) - } +/// `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, } -/// A deployed contract has no version — only a wasm does. Reject the `@version` -/// syntax `import_contract_client!` accepts, pointing the caller at the plain form. -fn reject_version(raw: &str) -> Result<(), String> { - if raw.contains('@') { - 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)`." - )) - } else { - Ok(()) +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 }) } } -/// Env var a caller can set to bypass the network: -/// `STELLAR_CONTRACT_ID_`, NAME = uppercased module name with any -/// non-alphanumeric replaced by `_`. -fn env_var_name(mod_name: &str) -> String { - let sanitized: String = mod_name - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_uppercase() - } else { - '_' - } - }) - .collect(); - format!("STELLAR_CONTRACT_ID_{sanitized}") -} - /// Validate a `C…` contract strkey; return it trimmed. fn validate_contract_id(s: &str) -> Result { let t = s.trim(); @@ -82,76 +38,132 @@ fn validate_contract_id(s: &str) -> Result { .map_err(|_| format!("not a valid contract id (C… strkey): {t:?}")) } -/// `/.id` — cached deployed address. Keyed by name only: -/// a deployed instance's address is version-independent. -fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { - target_dir.join(mod_name).with_extension("id") +/// 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) } -/// `/.wasm` — the deployed contract's wasm, fetched by -/// address, that `contractimport!` reads to generate the client types. -fn cache_wasm_path(target_dir: &Path, mod_name: &str) -> PathBuf { - target_dir.join(mod_name).with_extension("wasm") +/// 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}\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. Precedence, first hit wins: -/// 1. `STELLAR_CONTRACT_ID_` env override — explicit, no flag check. -/// 2. `STELLAR_NO_REGISTRY=1` — offline: the `.id` cache, no flag check. -/// 3. otherwise online — `fetch` (which also fails if the contract is flagged) -/// and refresh the cache. The cache is deliberately NOT consulted online, -/// so a contract flagged after the first build cannot slip through a stale -/// `.id`. All IO is injected so precedence is unit-testable offline. +/// 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( - env_lookup: impl Fn(&str) -> Option, - env_var: &str, cache: Option, no_registry: bool, + offline_help: &str, fetch: impl FnOnce() -> Result, ) -> Result { - if let Some(v) = env_lookup(env_var) { - return validate_contract_id(&v); - } if no_registry { return match cache { Some(c) => validate_contract_id(&c), None => Err(format!( - "STELLAR_NO_REGISTRY=1 but no cached contract id. Set {env_var}, or build \ - online once (which caches it), then rebuild offline." + "STELLAR_NO_REGISTRY=1 but no cached contract id. Things to try:\n\n{offline_help}" )), }; } validate_contract_id(&fetch()?) } -/// Shell out to the `stellar` CLI to look up a deployed contract's id by name, -/// failing if it is flagged as compromised (`--reject-flagged`). Network -/// selection is delegated to the CLI's own config (`STELLAR_NETWORK`). -fn fetch_contract_id(lookup_name: &str) -> Result { +/// Shell out to the `stellar` 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", - lookup_name, - "--reject-flagged", - ]) + .args(["registry", "fetch-contract-id"]) + .arg(lookup.to_string()) .output() .map_err(|e| { format!( - "failed to run `stellar registry fetch-contract-id`: {e}. \ - Install it with `cargo install stellar-registry-cli` and try again." + "failed to run `stellar`: {e}. Install the Stellar CLI, then \ + `cargo install stellar-registry-cli` for the registry plugin." ) })?; if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - } else { - Err(format!( - "Could not resolve `{lookup_name}`. It may not be registered, may be flagged as \ - compromised, or the network may be wrong (https://stellar.rgstry.xyz). Run \ - `stellar registry fetch-contract-id {lookup_name}` yourself, or set \ - STELLAR_NO_REGISTRY=1 with a cached id to skip the registry lookup.\n{}", - String::from_utf8_lossy(&out.stderr) - )) + 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 @@ -177,33 +189,11 @@ fn fetch_wasm(address: &str, out_path: &Path) -> Result<(), String> { } } -enum Name { - Ident(Ident), - LitStr(LitStr), -} - -/// `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 = if input.peek(LitStr) { - Name::LitStr(input.parse::()?) - } else { - Name::Ident(input.parse::()?) - }; - Ok(Self { env, name }) - } -} - /// 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, @@ -212,8 +202,10 @@ fn expand( ) -> proc_macro2::TokenStream { quote! { { + #[allow(non_snake_case)] mod #mod_ident { - use super::soroban_sdk; + #![allow(clippy::ref_option, clippy::too_many_arguments)] + use ::soroban_sdk; soroban_sdk::contractimport!(file = #wasm_path); } let __env: &::soroban_sdk::Env = #env; @@ -225,91 +217,66 @@ fn expand( } } -/// 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(/* ... */); -/// ``` -/// -/// `name` is a bare ident or string literal, optionally channel-prefixed -/// (`import_contract!(env, "unverified/our_dao")`). A deployed contract has no -/// version, so **no `@version` is accepted**. The client types are generated -/// from the deployed contract's *own* on-chain wasm, so a contract whose wasm -/// was never published to the registry still works. -/// -/// Resolved at build time: -/// - **address** — `STELLAR_CONTRACT_ID_` env override → (offline only) -/// `target/stellar//.id` cache → `stellar registry -/// fetch-contract-id`. The online path **fails compilation if the contract is -/// flagged as compromised** in the registry. -/// - **wasm** — `stellar contract fetch --id
`, cached beside the id. -/// -/// `STELLAR_NO_REGISTRY=1` forbids the network calls (requires a cached id + -/// wasm, and skips the flag check). Because a real on-chain address is baked in, -/// use this in real / integration builds; if the named contract is redeployed or -/// upgraded, clear the cache (`cargo clean`) and rebuild. -#[proc_macro] -pub fn import_contract(input: TokenStream) -> TokenStream { - let Input { env, name } = parse_macro_input!(input as Input); - - let err = - |msg: String| -> TokenStream { syn::Error::new(name_span, msg).to_compile_error().into() }; - - if let Err(msg) = reject_version(&name_raw) { - return err(msg); - } - let mod_name = mod_name_from(&name_raw); - if let Err(msg) = check_mod_name(&mod_name) { - return err(msg); +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 mod_ident = Ident::new(&mod_name, name_span); - let evar = env_var_name(&mod_name); + 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 = match stellar_build::get_target_dir(&manifest()) { - Ok(dir) => dir, - Err(e) => return err(format!("could not determine the cargo target dir: {e}")), - }; - let id_path = cache_id_path(&target_dir, &mod_name); - let wasm_path = cache_wasm_path(&target_dir, &mod_name); - let cache = std::fs::read_to_string(&id_path).ok(); + 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 address = match resolve_address( - |k| env::var(k).ok(), - &evar, - cache, - no_registry, - || { - let addr = validate_contract_id(&fetch_contract_id(&name_raw)?)?; - let _ = std::fs::write(&id_path, &addr); - Ok(addr) - }, - ) { - Ok(a) => a, - Err(msg) => return err(msg), - }; - - // 2. Ensure the deployed contract's wasm is on disk for `contractimport!`. - if !wasm_path.exists() { - if no_registry { - return err(format!( - "STELLAR_NO_REGISTRY=1 but no cached wasm at {}. Build online once (which \ - fetches it) then rebuild offline.", - wasm_path.display() - )); + 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); } - if let Err(msg) = fetch_wasm(&address, &wasm_path) { - return err(msg); + 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. - expand(&env, &mod_ident, &wasm_path.to_string_lossy(), &address).into() + Ok(expand( + &env, + &mod_ident, + &wasm_path.to_string_lossy(), + &address, + )) } #[cfg(test)] @@ -320,64 +287,71 @@ mod helpers { // A real, valid contract strkey (from soroban-sdk docs). const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; - #[test] - fn mod_name_strips_prefix_and_hyphens() { - assert_eq!( - mod_name_from("unverified/registry_tansu_manager"), - "registry_tansu_manager" - ); - assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); - assert_eq!(mod_name_from("a/b/c"), "c"); - assert_eq!(mod_name_from("registry"), "registry"); + fn prefixed(s: &str) -> Prefixed { + s.parse().unwrap() } #[test] - fn reject_version_rejects_at() { - assert!(reject_version("our_dao").is_ok()); - assert!(reject_version("unverified/our_dao").is_ok()); - assert!(reject_version("our_dao@v1.0.0").is_err()); - assert!(reject_version("our_dao@1.0.0").is_err()); + 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 env_var_name_uppercases_and_sanitizes() { + fn cache_paths_are_namespaced_under_deployed() { assert_eq!( - env_var_name("registry_tansu_manager"), - "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER" + cache_id_path(Path::new("target"), &prefixed("our-dao")), + Path::new("target/deployed/our_dao.id") ); assert_eq!( - env_var_name("guess_the_number"), - "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER" + cache_wasm_path(Path::new("target"), &prefixed("our-dao")), + Path::new("target/deployed/our_dao.wasm") ); } #[test] - fn validate_contract_id_trims_and_checks() { + fn cache_paths_include_the_channel() { + // `unverified/foo` and `foo` are different contracts — different files. assert_eq!( - validate_contract_id(&format!(" {VALID}\n")).unwrap(), - VALID + 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")), ); - assert!(validate_contract_id("not-an-address").is_err()); - assert!(validate_contract_id("").is_err()); } #[test] - fn cache_paths_are_target_siblings() { - assert_eq!( - cache_id_path(Path::new("target"), "our_dao"), - Path::new("target/our_dao.id") - ); - assert_eq!( - cache_wasm_path(Path::new("target"), "our_dao"), - Path::new("target/our_dao.wasm") - ); + 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 check_mod_name_rejects_empty_identifiers() { - assert!(check_mod_name("our_dao").is_ok()); - assert!(check_mod_name("").is_err()); - assert!(check_mod_name(&mod_name_from("foo/")).is_err()); + 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")); } } @@ -391,42 +365,32 @@ mod resolution { Err("fetch should not run".into()) } - #[test] - fn env_override_wins() { - let got = resolve_address( - |k| (k == "STELLAR_CONTRACT_ID_FOO").then(|| A.to_string()), - "STELLAR_CONTRACT_ID_FOO", - Some(B.to_string()), - false, - no_fetch, - ); - assert_eq!(got.unwrap(), A); - } - #[test] fn offline_uses_cache() { - let got = resolve_address(|_| None, "X", Some(B.to_string()), true, no_fetch); + let got = resolve_address(Some(B.to_string()), true, "help", no_fetch); assert_eq!(got.unwrap(), B); } #[test] - fn offline_errors_without_env_or_cache() { - let got = resolve_address(|_| None, "X", None, true, no_fetch); - assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); + 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( - |_| None, - "X", - Some(B.to_string()), - false, - || Ok(A.to_string()), - ); + 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)] @@ -441,32 +405,45 @@ mod codegen { #[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"); + 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"); + 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/our_dao.wasm", A).to_string(); + 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("import_contract_client"), - "does NOT delegate to import_contract_client!: {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}" diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs index 029464c..ec65f23 100644 --- a/crates/stellar-registry-macro/src/contract_client.rs +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -1,46 +1,21 @@ -use proc_macro::TokenStream; +use std::env; +use std::path::{Path, PathBuf}; + use proc_macro2::Span; use quote::quote; -use std::env; -use stellar_build::Network; -use syn::parse::{Parse, ParseStream, Result}; -use syn::{Ident, LitStr}; +use syn::{ + Ident, + parse::{Parse, ParseStream, Result}, +}; -pub(crate) fn manifest() -> std::path::PathBuf { - std::path::PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) - .join("Cargo.toml") -} +use stellar_registry_build::name::Versioned; -/// Generates a contract Client for a given contract. -/// The name should match a published contract or a contract in your current workspace. -/// -/// # Usage -/// -/// ```ignore -/// // For simple names (workspace contracts or registry names without hyphens): -/// import_contract_client!(registry); -/// -/// // For hyphenated names or channel-prefixed registry paths: -/// import_contract_client!("unverified/guess-the-number"); -/// -/// // For specific versions, use quotes. `v` is optional: -/// import_contract_client!("registry@v1.0.0"); -/// ``` -/// -/// When using a string literal, the module name is derived from the contract -/// name with hyphens replaced by underscores (e.g., `guess_the_number`). -/// -/// # Panics -/// -/// This function may panic in the following situations: -/// - If `stellar_build::get_target_dir()` fails to retrieve the target directory -/// - If the input tokens cannot be parsed as a valid identifier -/// - If the input tokens cannot be parsed as a valid identifier or string literal -/// - If the directory path cannot be canonicalized -/// - If the canonical path cannot be converted to a string +use crate::util::{Name, explorer_url, manifest, mod_ident, network_name}; -pub fn import_contract_client(wasm_binary: TokenStream) -> Result { - let WasmBinary { mod_name, file } = syn::parse::(wasm_binary)?; +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 { @@ -58,79 +33,63 @@ struct WasmBinary { impl Parse for WasmBinary { fn parse(input: ParseStream) -> Result { - let (lookup_name, mod_name, version) = parse_name_and_version(input)?; - let wasm_path = resolve_wasm_path(&lookup_name, &mod_name, version.as_deref())?; + 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 }) } } -/// Parse a contract name with an optional version specifier. -/// -/// Accepts an identifier like `registry` or a string like -/// `"unverified/guess-the-number"`, optionally followed by `@VERSION` -/// (e.g. `"registry@v1.4.0"` or `"registry@1.4.0"`). Returns the bare -/// lookup name, a sanitized module identifier, and the parsed version -/// (with any leading `v` stripped). -fn parse_name_and_version(input: ParseStream) -> Result<(String, Ident, Option)> { - let span = input.span(); - let raw = if input.peek(LitStr) { - input.parse::()?.value() - } else { - input.parse::()?.to_string() +/// `[__][_]` — 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 regex::Regex::new(r"(^/)|(/$)").unwrap().is_match(&raw) { - return Err(syn::Error::new( - span, - format!("bad leading/trailing slash: `{raw}`"), - )); + if let Some(v) = version { + stem = format!("{stem}_{}", v.replace('.', "_")); } - - // Split off optional version: "name@v1.4.0" or "name@1.4.0" - let (name_part, version) = match raw.split_once('@') { - Some((name, ver)) => { - let ver = ver.strip_prefix('v').unwrap_or(ver); - (name.to_string(), Some(ver.to_string())) - } - None => (raw, None), - }; - - // Derive a valid Rust identifier for the module name (no version) - // e.g. "unverified/guess-the-number" -> "guess_the_number" - let mod_name_str = name_part - .rsplit('/') - .next() - .unwrap_or(&name_part) - .replace('-', "_"); - let mod_name = Ident::new(&mod_name_str, span); - - Ok((name_part, mod_name, version)) + stem } fn build_local_wasm_path( - target_dir: &std::path::Path, + target_dir: &Path, + channel: Option<&str>, mod_name: &Ident, version: Option<&str>, -) -> std::path::PathBuf { - let file_stem = match version { - Some(v) => format!("{}_{}", mod_name, v.replace('.', "_")), - None => mod_name.to_string(), - }; - target_dir.join(file_stem).with_extension("wasm") +) -> PathBuf { + target_dir + .join(wasm_file_stem(channel, mod_name, version)) + .with_extension("wasm") } -fn resolve_wasm_path( - lookup_name: &str, - mod_name: &Ident, - version: Option<&str>, -) -> Result { - let target_dir = stellar_build::get_target_dir(&manifest()).unwrap(); - let local_path = build_local_wasm_path(&target_dir, mod_name, version); +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 Ok(local_path.canonicalize().expect("canonicalize failed")); + return canonicalized(&local_path, span); } // 2. If STELLAR_NO_REGISTRY set to 1, error @@ -138,35 +97,48 @@ fn resolve_wasm_path( && &v == "1" { return Err(syn::Error::new( - mod_name.span(), + span, format!( "No local wasm found and STELLAR_NO_REGISTRY=1 so not checking Registry. \ - Download manually with `stellar registry download {lookup_name}`" + Download manually with `stellar registry download {}`", + wasm.name() ), )); } // 3. if var absent or set to something else, try to download - download_from_registry(lookup_name, &local_path, mod_name.span(), version) + 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( - lookup_name: &str, - local_path: &std::path::Path, + wasm: &Versioned, + local_path: &Path, span: Span, version: Option<&str>, -) -> Result { +) -> Result { + let lookup_name = wasm.name().to_string(); + // 1. create `target/stellar/[network]` directory, if not already present - let parent = local_path.parent().expect("no parent"); - if !parent.exists() { - std::fs::create_dir_all(parent).expect("creating parent directory failed"); + 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.to_string(), + lookup_name.clone(), "--out-file".to_string(), local_path.display().to_string(), ]; @@ -174,191 +146,67 @@ fn download_from_registry( args.push("--version".to_string()); args.push(v.to_string()); } - let status = std::process::Command::new("stellar") + let out = std::process::Command::new("stellar") .args(&args) - .status() - .expect( - "failed to execute `stellar registry download`; try `cargo install stellar-registry-cli` and try again", - ); - - // 3. check status - if status.success() && local_path.exists() { - Ok(local_path.canonicalize().expect("canonicalize failed")) - } else { - let local_path = local_path.display().to_string(); - Err(syn::Error::new( + .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!( - "Could not find Wasm `{lookup_name}`. Checked: \ - \n\n• {local_path} \ - \n• `stellar registry download {lookup_name}` \ - \n\nYou can: \ - \n\n1. check the name & network and try again (https://stellar.rgstry.xyz) \ - \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." + "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}" ), - )) - } -} - -/// Generates a contract Client for a given asset. -/// It is expected that the name of an asset, e.g. "native" or "USDC:G1...." -/// -/// # Panics -/// -#[proc_macro] -pub fn import_asset(input: TokenStream) -> TokenStream { - // Parse the input as a string literal - let input_str = syn::parse_macro_input!(input as syn::LitStr); - asset::parse_literal(&input_str, &Network::passphrase_from_env()).into() -} - -#[cfg(test)] -mod parse_name_and_version { - use super::*; - use syn::parse::Parser; - - #[test] - fn parse_simple_name() { - let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!(registry)) - .unwrap(); - assert_eq!(mod_name.to_string(), "registry"); - assert_eq!(lookup_name, "registry"); - } - - #[test] - fn parse_channel_hyphenated_name() { - let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("guess-the-number")) - .unwrap(); - assert_eq!(mod_name.to_string(), "guess_the_number"); - assert_eq!(lookup_name, "guess-the-number"); - } - - #[test] - fn parse_channel_prefixed_name() { - let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("unverified/guess-the-number")) - .unwrap(); - assert_eq!(mod_name.to_string(), "guess_the_number"); - assert_eq!(lookup_name, "unverified/guess-the-number"); - } - - #[test] - fn parse_channel_simple_name() { - let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("unverified/hello")) - .unwrap(); - assert_eq!(mod_name.to_string(), "hello"); - assert_eq!(lookup_name, "unverified/hello"); - } - - #[test] - fn parse_underscored_name() { - let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("my_contract")) - .unwrap(); - assert_eq!(mod_name, "my_contract"); - assert_eq!(lookup_name, "my_contract"); - } - - #[test] - fn error_trailing_slash() { - let err = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("unverified/")) - .unwrap_err(); - assert!( - err.to_string() - .contains("bad leading/trailing slash: `unverified/`"), - "unexpected error: {err}" - ); - } - - #[test] - fn error_leading_slash() { - let err = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("/guess-the-number")) - .unwrap_err(); - assert!( - err.to_string() - .contains("bad leading/trailing slash: `/guess-the-number`"), - "unexpected error: {err}" - ); - } - - #[test] - fn multiple_slashes_returns_final_as_mod_name() { - let (lookup_name, mod_name, _) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("a/b/c")) - .unwrap(); - assert_eq!(mod_name, "c"); - assert_eq!(lookup_name, "a/b/c"); - } - - #[test] - #[should_panic(expected = "Ident is not allowed to be empty")] - fn error_empty_string() { - (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("")) - .unwrap(); - } - - #[test] - #[should_panic(expected = "not a valid Ident")] - fn error_starts_with_digit() { - (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("123bad")) - .unwrap(); - } - - #[test] - #[should_panic(expected = "not a valid Ident")] - fn error_invalid_characters() { - (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("hello world")) - .unwrap(); - } - - #[test] - #[should_panic(expected = "not a valid Ident")] - fn error_channel_prefixed_starts_with_digit() { - (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("unverified/1bad")) - .unwrap(); - } - - #[test] - fn main_channel_with_version() { - let (lookup_name, mod_name, version) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("registry@v1.0.1")) - .unwrap(); - assert_eq!(mod_name, "registry"); - assert_eq!(lookup_name, "registry"); - assert_eq!(&version.unwrap(), "1.0.1"); - } - - #[test] - fn unverified_channel_with_version() { - let (lookup_name, mod_name, version) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("unverified/guess-the-number@0.4.0")) - .unwrap(); - assert_eq!(mod_name, "guess_the_number"); - assert_eq!(lookup_name, "unverified/guess-the-number"); - assert_eq!(&version.unwrap(), "0.4.0"); + )); } - - #[test] - fn prerelease_version() { - let (lookup_name, mod_name, version) = (|input: ParseStream| parse_name_and_version(input)) - .parse2(quote!("registry@1.0.0-rc.1")) - .unwrap(); - assert_eq!(mod_name, "registry"); - assert_eq!(lookup_name, "registry"); - assert_eq!(&version.unwrap(), "1.0.0-rc.1"); + 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}"), + ); + 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)] @@ -372,19 +220,41 @@ mod test_build_local_wasm_path { #[test] fn includes_underscore_delimited_version() { - let path = build_local_wasm_path(Path::new("target"), &ident("a"), Some("1.0.0")); + 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"), &ident("registry"), None); + 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"), &ident("foo"), Some("1.0.0-rc.1")); + 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 index c35c306..fef3eba 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -1,6 +1,7 @@ -//! The `import_contract!` proc-macro: resolve a named Stellar Registry contract -//! to a type-safe client already bound to its deployed on-chain address, with -//! the client types generated from the deployed contract's own wasm. +//! 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; @@ -9,50 +10,88 @@ mod contract; mod contract_client; mod util; -use asset::import_asset; -use contract::import_contract; -use stellar_registry_build::macro_plus::*; +use util::ProcMacroWrapper as _; -/// Generates a contract Client for a given contract. -/// The name should match a published contract or a contract in your current workspace. +/// 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. /// -/// # Usage +/// ```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 +/// (with an up-to-date `stellar-registry-cli` plugin), 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 -/// // For simple names (workspace contracts or registry names without hyphens): +/// // Workspace contracts or registry names without hyphens: /// import_contract_client!(registry); /// -/// // For hyphenated names or channel-prefixed registry paths: +/// // Hyphenated or channel-prefixed registry names: /// import_contract_client!("unverified/guess-the-number"); /// -/// // For specific versions, use quotes. `v` is optional: -/// import_contract_client!("registry@v1.0.0"); +/// // A specific published version (leading `v` optional): +/// import_contract_client!("registry@1.0.0"); /// ``` /// -/// When using a string literal, the module name is derived from the contract -/// name with hyphens replaced by underscores (e.g., `guess_the_number`). -/// -/// # Panics +/// 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 `_`. /// -/// This function may panic in the following situations: -/// - If `stellar_build::get_target_dir()` fails to retrieve the target directory -/// - If the input tokens cannot be parsed as a valid identifier -/// - If the input tokens cannot be parsed as a valid identifier or string literal -/// - If the directory path cannot be canonicalized -/// - If the canonical path cannot be converted to a string +/// 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(wasm_binary: TokenStream) -> TokenStream { - contract_client::import_contract_client(wasm_binary).to_token_stream() +pub fn import_contract_client(input: TokenStream) -> TokenStream { + contract_client::import_contract_client(input).to_token_stream() } -/// Generates a contract Client for a given asset. -/// It is expected that the name of an asset, e.g. "native" or "USDC:G1...." +/// 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). /// -/// # Panics +/// ```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 { - // Parse the input as a string literal - let input_str = syn::parse_macro_input!(input as syn::LitStr); - asset::parse_literal(&input_str, &Network::passphrase_from_env()).into() + 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 index d074552..252b49f 100644 --- a/crates/stellar-registry-macro/src/util.rs +++ b/crates/stellar-registry-macro/src/util.rs @@ -1,21 +1,196 @@ use std::path::PathBuf; +use proc_macro2::Span; +use stellar_registry_build::name; +use syn::{ + Ident, LitStr, + parse::{Parse, ParseStream}, +}; + /// Path to the compiling crate's `Cargo.toml`. -fn manifest() -> syn::Result { - Ok(PathBuf::from( - std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "failed to find cargo manifest".into())?) - .join("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 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/contracts"), + "mainnet" => Some("https://stellar.rgstry.xyz/contracts"), + _ => 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; + fn to_token_stream(self) -> proc_macro::TokenStream; } impl ProcMacroWrapper for syn::Result { - fn to_token_stream(&self) -> proc_macro::TokenStream { - self.clone() - .map_or_else(|e| e.to_compile_error().into(), |inner| inner.into()) + 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_build::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_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/contracts") + ); + assert_eq!( + explorer_url("mainnet"), + Some("https://stellar.rgstry.xyz/contracts") + ); + assert_eq!(explorer_url("local"), None); + assert_eq!(explorer_url("futurenet"), None); } } diff --git a/crates/stellar-registry/Cargo.toml b/crates/stellar-registry/Cargo.toml index 8f0f348..af5fb0e 100644 --- a/crates/stellar-registry/Cargo.toml +++ b/crates/stellar-registry/Cargo.toml @@ -13,5 +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/src/lib.rs b/crates/stellar-registry/src/lib.rs index 82e45d1..7d820c0 100644 --- a/crates/stellar-registry/src/lib.rs +++ b/crates/stellar-registry/src/lib.rs @@ -2,5 +2,4 @@ //! `stellar-registry` is a collection of tools to help integrate with //! existing smart contracts on Stellar. //! -pub use stellar_registry_macro::import_contract; -pub use stellar_scaffold_macro::*; +pub use stellar_registry_macro::{import_asset, import_contract, import_contract_client}; diff --git a/docs/superpowers/plans/2026-07-02-import-contract-macro.md b/docs/superpowers/plans/2026-07-02-import-contract-macro.md deleted file mode 100644 index 45b312d..0000000 --- a/docs/superpowers/plans/2026-07-02-import-contract-macro.md +++ /dev/null @@ -1,632 +0,0 @@ -# `import_contract!` Macro Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `stellar_registry::import_contract!(env, name)` proc-macro that returns a type-safe soroban `Client` already bound to the named contract's deployed on-chain address, resolved at build time. - -**Architecture:** A new `proc-macro = true` crate `stellar-registry-macro` in the `cli` workspace, re-exported from `stellar-registry`. The macro delegates wasm/type generation to the existing `import_contract_client!`, resolves the deployed address at build time (env override → `.id` cache → `stellar registry fetch-contract-id` shell-out, gated by `STELLAR_NO_REGISTRY`), and emits `mod_name::Client::new(env, &Address::from_str(env, "C…"))`. - -**Tech Stack:** Rust (edition 2024), `syn` 2 / `quote` / `proc-macro2`, `stellar-build` (target-dir/network), `stellar-strkey` (address validation), the `stellar` CLI (`registry fetch-contract-id`). - -**Design spec:** `docs/superpowers/specs/2026-07-02-import-contract-macro-design.md`. **Issue:** stellar-scaffold/cli#419. - -> **Revised post-review (2026-07-14, PR #17):** the "delegates wasm/type -> generation to `import_contract_client!`" architecture above was rejected. See -> the design spec's "Revision (post-review)" section — the macro takes no -> `@version`, generates types from the deployed contract's own on-chain wasm -> (`stellar contract fetch --id`), and fails compilation if the contract is -> flagged (`fetch-contract-id --reject-flagged`, a raw `ContractEntry` ledger read). - -## Global Constraints - -- Rust **edition 2024** (matches the existing `stellar-registry` crate). -- **Strict clippy pedantic** — code must pass `just clippy` (`-Dclippy::pedantic`). -- Dep versions (match `stellar-scaffold-macro` 0.8.14): `proc-macro2 = "1.0"`, `quote = "1.0"`, `syn = { version = "2", features = ["full"] }`, `stellar-build` (workspace `0.0.6`), `stellar-strkey` (workspace `0.0.15`). -- The macro crate **must not** depend on `soroban-sdk`; it emits `::soroban_sdk::…` paths that resolve in the consumer crate. -- Address lookup is by **name only** (deployed instances are named, not versioned); the wasm keeps version semantics via the delegated `import_contract_client!`. -- Preserve hyphens in the value passed to `fetch-contract-id` (`PrefixedName` does no `_`/`-` normalization); only the *module name* gets `-`→`_`. - ---- - -### Task 1: New crate skeleton, workspace wiring, and pure helpers - -**Files:** -- Create: `crates/stellar-registry-macro/Cargo.toml` -- Create: `crates/stellar-registry-macro/src/lib.rs` -- Modify: `Cargo.toml` (root workspace — add three build deps + the path dep) -- Test: unit tests inside `crates/stellar-registry-macro/src/lib.rs` (`#[cfg(test)]`) - -**Interfaces:** -- Produces (used by later tasks): `fn mod_name_from(&str) -> String`, `fn split_version(&str) -> (String, Option)`, `fn env_var_name(&str) -> String`, `fn validate_contract_id(&str) -> Result`, `fn cache_id_path(&Path, &str) -> PathBuf`, `fn manifest() -> PathBuf`. - -- [ ] **Step 1: Create the crate manifest** - -Create `crates/stellar-registry-macro/Cargo.toml`: - -```toml -[package] -name = "stellar-registry-macro" -version = "0.0.1" -edition = "2024" -description = "The import_contract! macro for the 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 } -stellar-strkey = { workspace = true } - -[lints] -workspace = true -``` - -- [ ] **Step 2: Wire the workspace dependencies** - -In the root `Cargo.toml` `[workspace.dependencies]`, add under `# Local crates`: - -```toml -stellar-registry-macro = { path = "crates/stellar-registry-macro" } -``` - -and add these three build-macro deps (they are not yet in the workspace): - -```toml -proc-macro2 = "1.0" -quote = "1.0" -syn = { version = "2", features = ["full"] } -``` - -(`stellar-strkey = "0.0.15"` and `stellar-build = "0.0.6"` already exist in `[workspace.dependencies]`.) - -- [ ] **Step 3: Write the failing helper tests** - -Create `crates/stellar-registry-macro/src/lib.rs` with the test module first: - -```rust -#[cfg(test)] -mod helpers { - use super::*; - use std::path::Path; - - // A real, valid contract strkey (from soroban-sdk docs). - const VALID: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; - - #[test] - fn mod_name_strips_prefix_and_hyphens() { - assert_eq!(mod_name_from("unverified/registry_tansu_manager"), "registry_tansu_manager"); - assert_eq!(mod_name_from("guess-the-number"), "guess_the_number"); - assert_eq!(mod_name_from("a/b/c"), "c"); - assert_eq!(mod_name_from("registry"), "registry"); - } - - #[test] - fn split_version_optional_v() { - assert_eq!(split_version("our_dao@v0.1.0"), ("our_dao".into(), Some("0.1.0".into()))); - assert_eq!(split_version("x@1.2.3"), ("x".into(), Some("1.2.3".into()))); - assert_eq!(split_version("x"), ("x".into(), None)); - } - - #[test] - fn env_var_name_uppercases_and_sanitizes() { - assert_eq!(env_var_name("registry_tansu_manager"), "STELLAR_CONTRACT_ID_REGISTRY_TANSU_MANAGER"); - assert_eq!(env_var_name("guess_the_number"), "STELLAR_CONTRACT_ID_GUESS_THE_NUMBER"); - } - - #[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_id_path_is_wasm_sibling() { - assert_eq!(cache_id_path(Path::new("target"), "our_dao"), Path::new("target/our_dao.id")); - } -} -``` - -- [ ] **Step 4: Run the tests to verify they fail to compile** - -Run: `cargo test -p stellar-registry-macro` -Expected: FAIL — `cannot find function mod_name_from` (and the others). - -- [ ] **Step 5: Implement the helpers** - -Prepend to `crates/stellar-registry-macro/src/lib.rs` (above the test module): - -```rust -//! The `import_contract!` proc-macro: resolve a named Stellar Registry contract -//! to a type-safe client already bound to its deployed on-chain address. -extern crate proc_macro; - -use std::{ - env, - path::{Path, PathBuf}, -}; - -/// Path to the compiling crate's `Cargo.toml`. -fn manifest() -> PathBuf { - PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("failed to find cargo manifest")) - .join("Cargo.toml") -} - -/// Rust module identifier from a (possibly channel-prefixed) registry name: -/// final `/`-segment with `-` replaced by `_`. -fn mod_name_from(name_part: &str) -> String { - name_part - .rsplit('/') - .next() - .unwrap_or(name_part) - .replace('-', "_") -} - -/// Split `"name@v1.2.3"` / `"name@1.2.3"` into `(name, version-without-leading-v)`. -fn split_version(raw: &str) -> (String, Option) { - match raw.split_once('@') { - Some((name, ver)) => ( - name.to_string(), - Some(ver.strip_prefix('v').unwrap_or(ver).to_string()), - ), - None => (raw.to_string(), None), - } -} - -/// Env var a caller can set to bypass the network: -/// `STELLAR_CONTRACT_ID_`, NAME = uppercased module name with any -/// non-alphanumeric replaced by `_`. -fn env_var_name(mod_name: &str) -> String { - let sanitized: String = mod_name - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }) - .collect(); - format!("STELLAR_CONTRACT_ID_{sanitized}") -} - -/// 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:?}")) -} - -/// `/.id` — sibling of the wasm the client imports. -/// Keyed by name only: a deployed instance's address is version-independent. -fn cache_id_path(target_dir: &Path, mod_name: &str) -> PathBuf { - target_dir.join(mod_name).with_extension("id") -} -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `cargo test -p stellar-registry-macro` -Expected: PASS (5 tests in `helpers`). - -- [ ] **Step 7: Commit** - -```bash -git add crates/stellar-registry-macro Cargo.toml -git commit -m "feat: stellar-registry-macro crate skeleton + pure helpers" -``` - ---- - -### Task 2: Build-time address resolution - -**Files:** -- Modify: `crates/stellar-registry-macro/src/lib.rs` -- Test: `#[cfg(test)]` module in the same file - -**Interfaces:** -- Consumes: `validate_contract_id` (Task 1). -- Produces: `fn resolve_address(env_lookup, env_var, cache, no_registry, fetch) -> Result` (all IO injected) and `fn fetch_contract_id(&str) -> Result` (real shell-out, used by Task 4). - -- [ ] **Step 1: Write the failing resolution tests** - -Add to `crates/stellar-registry-macro/src/lib.rs`: - -```rust -#[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 env_override_wins() { - let got = resolve_address( - |k| (k == "STELLAR_CONTRACT_ID_FOO").then(|| A.to_string()), - "STELLAR_CONTRACT_ID_FOO", - Some(B.to_string()), - false, - no_fetch, - ); - assert_eq!(got.unwrap(), A); - } - - #[test] - fn cache_used_when_no_env() { - let got = resolve_address(|_| None, "X", Some(B.to_string()), false, no_fetch); - assert_eq!(got.unwrap(), B); - } - - #[test] - fn no_registry_errors_without_env_or_cache() { - let got = resolve_address(|_| None, "X", None, true, no_fetch); - assert!(got.unwrap_err().contains("STELLAR_NO_REGISTRY")); - } - - #[test] - fn fetch_is_last_resort() { - let got = resolve_address(|_| None, "X", None, false, || Ok(A.to_string())); - assert_eq!(got.unwrap(), A); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cargo test -p stellar-registry-macro resolution` -Expected: FAIL — `cannot find function resolve_address`. - -- [ ] **Step 3: Implement resolution + shell-out** - -Add to `crates/stellar-registry-macro/src/lib.rs` (above the test modules): - -```rust -use std::process::Command; - -/// Resolve the deployed address, first hit wins. All IO is injected so the -/// precedence is unit-testable without a network or filesystem. -fn resolve_address( - env_lookup: impl Fn(&str) -> Option, - env_var: &str, - cache: Option, - no_registry: bool, - fetch: impl FnOnce() -> Result, -) -> Result { - if let Some(v) = env_lookup(env_var) { - return validate_contract_id(&v); - } - if let Some(c) = cache { - return validate_contract_id(&c); - } - if no_registry { - return Err(format!( - "No cached contract id and STELLAR_NO_REGISTRY=1 so not checking the Registry. \ - Set {env_var}, or run `stellar registry fetch-contract-id ` and rebuild." - )); - } - validate_contract_id(&fetch()?) -} - -/// Shell out to the `stellar` CLI to look up a deployed contract's id by name. -/// Network selection is delegated to the CLI's own config (`STELLAR_NETWORK`). -fn fetch_contract_id(lookup_name: &str) -> Result { - let out = Command::new("stellar") - .args(["registry", "fetch-contract-id", lookup_name]) - .output() - .map_err(|e| { - format!( - "failed to run `stellar registry fetch-contract-id`: {e}. \ - Install it with `cargo install stellar-registry-cli` and try again." - ) - })?; - if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - } else { - Err(format!( - "Could not resolve a contract id for `{lookup_name}`. \ - Check the name & network and try again (https://stellar.rgstry.xyz), \ - run `stellar registry fetch-contract-id {lookup_name}` yourself, \ - or set STELLAR_NO_REGISTRY=1 to skip the registry lookup.\n{}", - String::from_utf8_lossy(&out.stderr) - )) - } -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cargo test -p stellar-registry-macro resolution` -Expected: PASS (4 tests). `fetch_contract_id` is exercised in Task 4 / manual integration, not unit tests. - -- [ ] **Step 5: Commit** - -```bash -git add crates/stellar-registry-macro/src/lib.rs -git commit -m "feat: build-time address resolution for import_contract!" -``` - ---- - -### Task 3: Macro input parsing and code generation - -**Files:** -- Modify: `crates/stellar-registry-macro/src/lib.rs` -- Test: `#[cfg(test)]` module in the same file - -**Interfaces:** -- Consumes: `mod_name_from`, `split_version` (Task 1). -- Produces: `struct Input { env: Expr, name_raw: String, name_span: Span }` (implements `syn::parse::Parse`) and `fn expand(&Expr, &str, &Ident, &str) -> proc_macro2::TokenStream` (used by Task 4). - -- [ ] **Step 1: Write the failing codegen test** - -Add to `crates/stellar-registry-macro/src/lib.rs`: - -```rust -#[cfg(test)] -mod codegen { - use super::*; - use quote::quote; - use syn::{parse2, Ident}; - use proc_macro2::Span; - - const A: &str = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322"; - - #[test] - fn parses_env_and_string_name() { - let input: Input = parse2(quote!(env, "unverified/our_dao@v0.1.0")).unwrap(); - assert_eq!(input.name_raw, "unverified/our_dao@v0.1.0"); - } - - #[test] - fn parses_env_and_ident_name() { - let input: Input = parse2(quote!(env, registry)).unwrap(); - assert_eq!(input.name_raw, "registry"); - } - - #[test] - fn expand_emits_delegation_and_bound_client() { - let env: syn::Expr = parse2(quote!(env)).unwrap(); - let ident = Ident::new("our_dao", Span::call_site()); - let out = expand(&env, "unverified/our_dao@v0.1.0", &ident, A).to_string(); - assert!(out.contains("import_contract_client"), "delegates wasm import: {out}"); - assert!(out.contains("\"unverified/our_dao@v0.1.0\""), "passes original name: {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}"); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cargo test -p stellar-registry-macro codegen` -Expected: FAIL — `cannot find type Input` / `cannot find function expand`. - -- [ ] **Step 3: Implement parsing and codegen** - -Add to `crates/stellar-registry-macro/src/lib.rs` (above the test modules): - -```rust -use proc_macro2::Span; -use quote::quote; -use syn::{ - parse::{Parse, ParseStream}, - Expr, Ident, LitStr, Token, -}; - -/// `import_contract!(env_expr, name)` — `name` is a bare ident or a string -/// literal using the same grammar as `import_contract_client!`. -struct Input { - env: Expr, - name_raw: String, - name_span: Span, -} - -impl Parse for Input { - fn parse(input: ParseStream) -> syn::Result { - let env: Expr = input.parse()?; - input.parse::()?; - let name_span = input.span(); - let name_raw = if input.peek(LitStr) { - input.parse::()?.value() - } else { - input.parse::()?.to_string() - }; - Ok(Self { env, name_raw, name_span }) - } -} - -/// Emit a block expression: delegate wasm/type generation to -/// `import_contract_client!`, then construct the client bound to the baked -/// address. `name_raw` is passed through verbatim (version included) so the -/// delegated macro resolves the matching wasm. -fn expand(env: &Expr, name_raw: &str, mod_ident: &Ident, address: &str) -> proc_macro2::TokenStream { - quote! { - { - ::stellar_registry::import_contract_client!(#name_raw); - let __env: &::soroban_sdk::Env = #env; - #mod_ident::Client::new( - __env, - &::soroban_sdk::Address::from_str(__env, #address), - ) - } - } -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cargo test -p stellar-registry-macro codegen` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add crates/stellar-registry-macro/src/lib.rs -git commit -m "feat: parse import_contract! input and generate the bound client" -``` - ---- - -### Task 4: `#[proc_macro]` entry point + re-export from `stellar-registry` - -**Files:** -- Modify: `crates/stellar-registry-macro/src/lib.rs` (add the `#[proc_macro]` fn) -- Modify: `crates/stellar-registry/Cargo.toml` (depend on the macro crate) -- Modify: `crates/stellar-registry/src/lib.rs` (re-export) - -**Interfaces:** -- Consumes: `Input`, `expand` (Task 3); `resolve_address`, `fetch_contract_id` (Task 2); `mod_name_from`, `split_version`, `env_var_name`, `cache_id_path`, `manifest` (Task 1). -- Produces: `stellar_registry::import_contract!` usable by consumers. - -- [ ] **Step 1: Implement the proc-macro entry point** - -Add to `crates/stellar-registry-macro/src/lib.rs`: - -```rust -use proc_macro::TokenStream; -use syn::parse_macro_input; - -/// Generate a type-safe client for a deployed, registry-named contract, -/// already bound to its on-chain address (resolved at build time). -/// -/// ```ignore -/// // `env: &Env` -/// let dao = stellar_registry::import_contract!(env, our_dao); -/// dao.create_proposal(/* ... */); -/// ``` -/// -/// `name` accepts the same forms as [`import_contract_client!`]: -/// `our_dao`, `"unverified/our_dao"`, `"our_dao@v1.0.0"`. -/// -/// The address is resolved at build time: `STELLAR_CONTRACT_ID_` env -/// override → `target/stellar//.id` cache → -/// `stellar registry fetch-contract-id`. `STELLAR_NO_REGISTRY=1` forbids the -/// network call. Because a real on-chain address is baked in, use this in -/// real / integration builds; in `soroban_sdk` unit tests keep -/// `import_contract_client!` plus your own `Client::new(env, &test_addr)`. -#[proc_macro] -pub fn import_contract(input: TokenStream) -> TokenStream { - let Input { env, name_raw, name_span } = parse_macro_input!(input as Input); - let (name_part, _version) = split_version(&name_raw); - let mod_name = mod_name_from(&name_part); - let mod_ident = Ident::new(&mod_name, name_span); - let evar = env_var_name(&mod_name); - - let no_registry = env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1"); - let cache_path = stellar_build::get_target_dir(&manifest()) - .ok() - .map(|dir| cache_id_path(&dir, &mod_name)); - let cache = cache_path.as_ref().and_then(|p| std::fs::read_to_string(p).ok()); - - let resolved = resolve_address( - |k| env::var(k).ok(), - &evar, - cache, - no_registry, - || { - let addr = fetch_contract_id(&name_part)?; - if let Some(p) = &cache_path { - let _ = std::fs::write(p, &addr); - } - Ok(addr) - }, - ); - - match resolved { - Ok(address) => expand(&env, &name_raw, &mod_ident, &address).into(), - Err(msg) => syn::Error::new(name_span, msg).to_compile_error().into(), - } -} -``` - -- [ ] **Step 2: Verify the crate builds** - -Run: `cargo build -p stellar-registry-macro` -Expected: builds clean. - -- [ ] **Step 3: Depend on the macro crate from `stellar-registry`** - -In `crates/stellar-registry/Cargo.toml`, under `[dependencies]`, add: - -```toml -stellar-registry-macro = { workspace = true } -``` - -- [ ] **Step 4: Re-export the macro** - -In `crates/stellar-registry/src/lib.rs`, add below the existing `pub use stellar_scaffold_macro::*;`: - -```rust -pub use stellar_registry_macro::import_contract; -``` - -- [ ] **Step 5: Verify `stellar-registry` builds with the re-export** - -Run: `cargo build -p stellar-registry` -Expected: builds clean; `stellar_registry::import_contract` is now public. - -- [ ] **Step 6: Run the full crate test suite** - -Run: `cargo test -p stellar-registry-macro` -Expected: PASS — all `helpers`, `resolution`, `codegen` tests green. - -- [ ] **Step 7: Commit** - -```bash -git add crates/stellar-registry-macro/src/lib.rs crates/stellar-registry/Cargo.toml crates/stellar-registry/src/lib.rs -git commit -m "feat: wire import_contract! proc-macro and re-export from stellar-registry" -``` - ---- - -### Task 5: Lint pass, docs build, and end-to-end integration check - -**Files:** -- Modify: `crates/stellar-registry-macro/src/lib.rs` (only if clippy/doc requires) - -**Interfaces:** none new. - -- [ ] **Step 1: Run pedantic clippy across the workspace** - -Run: `just clippy` -Expected: no warnings. Fix any pedantic findings in `stellar-registry-macro` (likely `must_use`, `uninlined_format_args`) until clean. - -- [ ] **Step 2: Build docs** - -Run: `cargo doc -p stellar-registry-macro --no-deps` -Expected: builds; the `import_contract` rustdoc renders with the example. - -- [ ] **Step 3: Manual hermetic expansion check (no network)** - -In a scratch soroban contract crate that has a wasm fixture staged at `target/stellar//hello_world.wasm` and `soroban-sdk` + `stellar-registry` deps, add: - -```rust -let _c = stellar_registry::import_contract!(env, hello_world); // env: &Env -``` - -Run: `STELLAR_CONTRACT_ID_HELLO_WORLD=CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322 STELLAR_NETWORK=local cargo build` -Expected: compiles — confirms delegation to `import_contract_client!`, the `::soroban_sdk::Address::from_str` path, and env-override resolution all resolve together. If `::soroban_sdk::Address::from_str` is absent in the pinned soroban-sdk (verify item §C.1 of the spec), switch the emitted address construction to `::soroban_sdk::Address::from_string(&::soroban_sdk::String::from_str(__env, #address))` and re-run. - -- [ ] **Step 4: Commit any lint/doc fixes** - -```bash -git add crates/stellar-registry-macro/src/lib.rs -git commit -m "chore: satisfy pedantic clippy and docs for import_contract!" -``` - ---- - -## Follow-ups (out of scope for this plan) - -- **Publish** `stellar-registry-macro` and bump `stellar-registry` so the `contracts` repo can consume `import_contract!` across crates.io (per the cross-repo wiring in the umbrella CLAUDE.md). -- **Automated integration test** in the `contracts` repo (which already stages wasm fixtures and builds before test) exercising `import_contract!` end-to-end against a local registry. -- **Address lockfile** (`registry-ids.toml` + a refresh command) for reproducible multi-network builds, layered on the `.id` cache. - -## Self-Review - -- **Spec coverage:** crate layout (Task 1/4), macro surface (Task 3), delegation codegen (Task 3/4), 4-step resolution incl. env override / cache / `STELLAR_NO_REGISTRY` / shell-out (Task 2/4), `compile_error!` handling (Task 2/4), pure + codegen tests (Task 1–3), rustdoc + unit-test caveat (Task 4/5). All spec sections map to a task. -- **Type consistency:** `resolve_address` / `fetch_contract_id` / `expand` / `Input` signatures are identical where produced (Task 2/3) and consumed (Task 4). `cache_id_path` takes `(&Path, &str)` everywhere (version dropped by design — address is version-independent). -- **Placeholder scan:** every code step contains complete code; the only conditional is the documented soroban-sdk API fallback in Task 5 Step 3, tied to spec verify-item §C.1. diff --git a/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md b/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md deleted file mode 100644 index 41587b3..0000000 --- a/docs/superpowers/specs/2026-07-02-import-contract-macro-design.md +++ /dev/null @@ -1,213 +0,0 @@ -# `import_contract!` macro — Design - -**Issue:** stellar-scaffold/cli#419 — `` `import_contract!` macro `` -**Repo:** `stellar-registry/cli` -**Date:** 2026-07-02 - -## Revision (post-review, 2026-07-14) - -Review (`stellar-registry/cli#17`) rejected the original codegen approach below. -`import_contract!` resolves a *contract*, not a *wasm*, and the two are not the -same thing. The implemented design differs from §B–§D as follows: - -1. **No `@version`.** A deployed contract has no version (only a wasm does). The - macro rejects `@` with a `compile_error!`. -2. **No delegation to `import_contract_client!`.** That resolves a wasm by - *name*, wrongly assuming the contract's name equals its wasm's name. Instead - the macro fetches the deployed contract's *own* on-chain wasm by address - (`stellar contract fetch --id --out-file …`) and inlines - `soroban_sdk::contractimport!(file = …)` — so a contract whose wasm was never - published to the registry still works (the §C "Fallback" is now the primary). -3. **Fail compilation if the contract is flagged** (`#38`, `#52`). No on-chain - getter exists, so the build reads the registry's `ContractEntry` persistent - ledger entry directly via RPC (key `(Symbol("CR"), )`; a - 3-element vec = flagged) behind a new `fetch-contract-id --reject-flagged`. -4. **Resolution precedence** (supersedes §D): `STELLAR_CONTRACT_ID_` env - override → (only under `STELLAR_NO_REGISTRY=1`) the `.id` cache → online - `fetch-contract-id --reject-flagged`. Online builds do **not** trust the `.id` - cache, so a contract flagged after the first build cannot slip through; env - override and offline mode are the explicit opt-outs of the flag check. -5. **Self-contained rustdoc** — no reference to `import_contract_client!`. - -## Goal - -Make cross-contract calls to a *named* Stellar Registry contract a one-liner: - -```rust -pub fn thing_doer(env: &Env) { - let dao = stellar_registry::import_contract!(env, our_dao); - dao.create_proposal(/* ... */); -} -``` - -`import_contract!` returns a ready-to-call, type-safe `Client` **already bound to the -deployed contract's on-chain address** — collapsing today's two steps into one. - -## Motivation - -Today a consumer writes two things (see `contracts/registry-tansu-manager/src/lib.rs`): - -```rust -stellar_registry::import_contract_client!(tansu_stub); // 1. generate the type -// ... -let c = tansu_stub::Client::new(env, &tansu); // 2. supply the Address by hand -``` - -`import_contract_client!` resolves the **wasm** by name (for the generated `Client` -type) but knows nothing about **where the contract is deployed**. The caller must -obtain the `Address` separately. `import_contract!` adds the missing half: resolve the -deployed address by name from the registry and bake it in. - -## Decisions (locked during brainstorming) - -1. **Address model — build-time bake.** Resolve name → address at *compile time* via - RPC and embed the address as a constant. Matches the issue's wording ("when you - build your contract … the macro would need to make network calls to look up the - contract"), mirrors how `import_contract_client!` downloads the wasm at build time, - and has zero runtime cost. Trade-off: the address is frozen at build; if the named - contract is redeployed, rebuild. -2. **Home — new local crate `stellar-registry-macro`** in this repo (not the external - `stellar-scaffold-macro`). Keeps registry-specific logic (`fetch-contract-id`) in - the registry's own repo and keeps the work executable here. -3. **Offline resolution — mirror `import_contract_client!`, plus an env override - checked first.** Consistency with the existing macro is the dominant value; the env - override makes CI / unit-test builds hermetic without a lockfile's tooling weight. - -## Non-goals (YAGNI) - -- Committed address lockfile (`registry-ids.toml`) with a `refresh` command. The `.id` - cache introduced here is a deliberate precursor if this is wanted later. -- Runtime on-chain address lookup (bake registry address, resolve target per call). -- Multiple addresses / address lists per import. -- Any change to `import_contract_client!` behavior. - -## Architecture - -### A. Crate layout - -New proc-macro crate: `crates/stellar-registry-macro`. - -- `Cargo.toml`: `[lib] proc-macro = true`; deps `syn`, `quote`, `proc-macro2`, - `stellar-build` (workspace). `syn`/`quote`/`proc-macro2` are added to - `[workspace.dependencies]` in the root `cli/Cargo.toml`. -- Root `cli/Cargo.toml` `[workspace.dependencies]` gains - `stellar-registry-macro = { path = "crates/stellar-registry-macro" }`. - (`members = ["crates/*"]` already auto-includes the new crate.) -- `crates/stellar-registry/Cargo.toml` adds `stellar-registry-macro = { workspace = true }`. -- `crates/stellar-registry/src/lib.rs` adds, beside the existing - `pub use stellar_scaffold_macro::*;`: - - ```rust - pub use stellar_registry_macro::import_contract; - ``` - -Consumers keep writing `stellar_registry::import_contract!(...)`. - -### B. Macro surface - -`import_contract!($env:expr, $name)`. - -- `$name` uses the **same grammar** as `import_contract_client!`: bare ident - (`registry`), string literal (`"unverified/our_dao"`), optional `@version` - (`"our_dao@v1.0.0"`), optional channel prefix. The module name is derived - identically: take the final `/`-segment, replace `-` with `_`. -- `$env` is an expression bound as `&Env`. **The caller passes `&env`** (an `&Env`); - documented in the macro docs. The expansion binds it once: - `let __env: &soroban_sdk::Env = $env;`. -- The macro expands to a **block expression** whose value is the constructed - `mod_name::Client`. - -### C. Codegen - -Primary approach — **delegate wasm/type generation to `import_contract_client!`** so no -resolution logic is duplicated: - -```rust -{ - stellar_registry::import_contract_client!(/* original $name tokens, verbatim */); - let __env: &soroban_sdk::Env = /* $env */ env; - our_dao::Client::new( - __env, - &soroban_sdk::Address::from_str(__env, "CABC…"), // baked, resolved at build - ) -} -``` - -- `import_contract_client!` emits `pub(crate) mod our_dao { use super::soroban_sdk; - soroban_sdk::contractimport!(file = "…our_dao.wasm"); }`. Inside the block its - `use super::soroban_sdk` resolves to the consumer's module — the **same** in-scope - `soroban_sdk` requirement the existing macro already imposes. -- `soroban_sdk::Address::from_str(env: &Env, strkey: &str) -> Address` is a real SDK - convenience (verified in soroban-sdk 26.0.0-rc.1 `src/address.rs`, wrapping - `from_string(&String::from_str(env, strkey))`; assumed stable in 27.0.0-rc.1 — verify - at build). -- The macro must recompute `mod_name` (last segment, `-`→`_`) to name the `Client` - path; it reuses the same derivation function `import_contract_client!` uses. - -**Fallback** if nesting a function-like proc-macro call inside generated output proves -fragile: inline the module ourselves — replicate the wasm-path resolution -(`resolve_wasm_path`) and emit `mod our_dao { … contractimport! … }` directly, then the -`Client::new` expression. Same output shape, no cross-macro dependency. - -### D. Address resolution (build time) - -Resolution order, first match wins: - -1. **Env override** — read `STELLAR_CONTRACT_ID_` (uppercased - `mod_name`, non-alphanumerics → `_`). If set, validate as a `C…` strkey and bake it. - Purpose: hermetic tests / CI with no files and no network. -2. **Cache file** — `target/stellar//.id`, a sibling of the wasm's - `.wasm`, where `` matches the wasm stem (`mod_name`, or - `mod_name_` when a version is given). Read, validate, - bake. Target dir + network come from `stellar_build::get_target_dir` / the network - env, exactly as `import_contract_client!` resolves the wasm path. -3. **`STELLAR_NO_REGISTRY=1`** — if set, emit `compile_error!` instead of any network - call (same escape hatch as the existing macro). -4. **RPC shell-out** — run `stellar registry fetch-contract-id `, capture - stdout (the `C…` address), validate the strkey, **write the `.id` cache file**, bake. - Network selection is delegated to the `stellar` CLI's own config/`STELLAR_NETWORK` - (the existing `download` shell-out passes no explicit network flag either). - -`` is the full name *including* any channel prefix and preserving hyphens -(e.g. `unverified/guess-the-number`) — `fetch-contract-id` takes a `PrefixedName` -positional and does no `_`/`-` normalization. - -### E. Error handling - -All failures are `compile_error!` at the macro call site: - -- **Invalid / empty strkey** (from any source) → error naming the contract and the - source (env var / cache file / CLI output). -- **CLI missing or fetch failed** → error mirroring `import_contract_client!`'s download - copy: check the name & network; try `stellar registry fetch-contract-id ` - yourself; set `STELLAR_NO_REGISTRY=1` to skip the registry lookup. - -### F. Testing - -- **Pure-helper unit tests** (mirror scaffold-macro's `parse_name_and_version` test - module): `mod_name` derivation, `STELLAR_CONTRACT_ID_*` env-var-name sanitization, - strkey validation, `.id` cache-path construction (with/without version). -- **Hermetic expansion test**: set the env override to a known `C…` address, expand, and - assert the generated tokens construct `mod_name::Client::new(env, &Address::from_str( - env, "C…"))`. No RPC. -- **Consumer caveat (documented, not code):** `import_contract!` bakes a *real network* - address, so it is for real / integration builds. In `soroban_sdk` unit tests the - dependency is registered at a fresh test-generated address, so the baked constant is - not usable there — unit tests should keep `import_contract_client!` + their own - `Client::new(env, &test_addr)`. This is why `registry-tansu-manager` (whose Tansu - address is deploy-time / stored) is **not** migrated to `import_contract!`. - -### G. Scope summary - -**In:** the new crate + macro; the 4-step resolution; `compile_error!` handling; pure + -expansion tests; macro rustdoc with a worked example. -**Out:** everything in Non-goals. - -## Open items to verify during implementation - -1. `soroban_sdk::Address::from_str` presence/signature in the exact pinned soroban-sdk - 27.0.0-rc.1 (checked against 26.0.0-rc.1; API expected stable). -2. Nesting `import_contract_client!` inside `import_contract!` output compiles cleanly; - if not, use the inline `contractimport!` fallback (§C). -3. Exact stdout format of `stellar registry fetch-contract-id` (currently - `println!("{contract_id}")` — a bare `C…` line; trim whitespace). From c2b1e2b9233331250c0710123cb6557bfadf2d7c Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Mon, 20 Jul 2026 21:19:35 +0200 Subject: [PATCH 13/31] feat: new stellar-registry-name crate to address final feedback --- Cargo.lock | 12 +- Cargo.toml | 3 +- crates/stellar-registry-build/Cargo.toml | 3 +- crates/stellar-registry-build/src/lib.rs | 3 +- crates/stellar-registry-build/src/name.rs | 75 ++------ crates/stellar-registry-build/src/registry.rs | 7 - crates/stellar-registry-cli/Cargo.toml | 5 +- .../src/commands/create_alias.rs | 2 +- .../src/commands/current_version.rs | 2 +- .../src/commands/deploy.rs | 5 +- .../src/commands/deploy_unnamed.rs | 5 +- .../src/commands/download.rs | 2 +- .../src/commands/fetch_contract_id.rs | 2 +- .../src/commands/fetch_hash.rs | 2 +- .../src/commands/upgrade.rs | 2 +- crates/stellar-registry-name/Cargo.toml | 19 ++ crates/stellar-registry-name/README.md | 3 + crates/stellar-registry-name/src/common.rs | 25 +++ crates/stellar-registry-name/src/error.rs | 23 +++ crates/stellar-registry-name/src/lib.rs | 15 ++ crates/stellar-registry-name/src/prefixed.rs | 168 ++++++++++++++++++ crates/stellar-registry-name/src/versioned.rs | 137 ++++++++++++++ 22 files changed, 443 insertions(+), 77 deletions(-) create mode 100644 crates/stellar-registry-name/Cargo.toml create mode 100644 crates/stellar-registry-name/README.md create mode 100644 crates/stellar-registry-name/src/common.rs create mode 100644 crates/stellar-registry-name/src/error.rs create mode 100644 crates/stellar-registry-name/src/lib.rs create mode 100644 crates/stellar-registry-name/src/prefixed.rs create mode 100644 crates/stellar-registry-name/src/versioned.rs diff --git a/Cargo.lock b/Cargo.lock index 2fb811b..1c01691 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4255,13 +4255,14 @@ dependencies = [ [[package]] name = "stellar-registry-build" -version = "0.0.10" +version = "0.0.9" dependencies = [ "expect-test", "semver", "sha2 0.10.9", "soroban-cli", "stellar-build", + "stellar-registry-name", "stellar-rpc-client", "stellar-strkey 0.0.16", "thiserror 2.0.18", @@ -4309,6 +4310,15 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index c36c370..984ea7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,8 @@ stellar-registry-test = { path = "crates/stellar-registry-test" } stellar-registry-macro = { path = "crates/stellar-registry-macro", version = "0.0.1" } # default-features = false so the proc-macro crate gets only the light `name` # module; network-facing consumers opt back in with features = ["cli"]. -stellar-registry-build = { path = "crates/stellar-registry-build", version = "0.0.10", default-features = false } +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`). diff --git a/crates/stellar-registry-build/Cargo.toml b/crates/stellar-registry-build/Cargo.toml index 9f93f37..c1a71b1 100644 --- a/crates/stellar-registry-build/Cargo.toml +++ b/crates/stellar-registry-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stellar-registry-build" -version = "0.0.10" +version = "0.0.9" edition = "2024" description = "A library using the registry at build time" license = "Apache-2.0" @@ -28,6 +28,7 @@ cli = [ [dependencies] stellar-cli = { workspace = true, optional = true, default-features = false, features = [] } stellar-build = { workspace = true, optional = true } +stellar-registry-name = { workspace = true } soroban-rpc = { workspace = true, optional = true } stellar-strkey = { workspace = true, optional = true } diff --git a/crates/stellar-registry-build/src/lib.rs b/crates/stellar-registry-build/src/lib.rs index 6be7c0c..88bf804 100644 --- a/crates/stellar-registry-build/src/lib.rs +++ b/crates/stellar-registry-build/src/lib.rs @@ -9,9 +9,10 @@ pub mod contract; #[cfg(feature = "cli")] pub mod error; -pub mod name; #[cfg(feature = "cli")] pub mod registry; +pub mod name; + #[cfg(feature = "cli")] pub use error::Error; diff --git a/crates/stellar-registry-build/src/name.rs b/crates/stellar-registry-build/src/name.rs index 4b44879..2f2e05d 100644 --- a/crates/stellar-registry-build/src/name.rs +++ b/crates/stellar-registry-build/src/name.rs @@ -1,63 +1,24 @@ -//! Typed, validated registry names. -//! -//! Parsing is the only way to construct these types, so holding one is proof -//! the name is structurally valid ("parse, don't validate"): -//! -//! - [`Prefixed`] — `name` or `channel/name`, no version. -//! - [`Versioned`] — a [`Prefixed`] plus an optional `@version` suffix. +pub use stellar_registry_name::*; -pub mod prefixed; -pub mod versioned; +#[cfg(feature = "cli")] +mod cli { + use stellar_cli::config; -pub use prefixed::Prefixed; -pub use versioned::Versioned; + use super::Prefixed; + use crate::registry::Registry; -#[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, - }, -} - -/// 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.chars() - .map(|c| { - if c == '_' { - '-' - } else { - c.to_ascii_lowercase() - } - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::canonicalize; + #[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; + } - #[test] - fn canonicalize_lowercases_and_hyphenates() { - assert_eq!(canonicalize("Guess_The_Number"), "guess-the-number"); - assert_eq!(canonicalize("registry"), "registry"); + impl RegistryAccess for Prefixed { + async fn registry(&self, config: &config::Args) -> Result { + Registry::from_named_registry(config, self).await + } } } + +#[cfg(feature = "cli")] +pub use cli::RegistryAccess; diff --git a/crates/stellar-registry-build/src/registry.rs b/crates/stellar-registry-build/src/registry.rs index be5d41c..e139dc5 100644 --- a/crates/stellar-registry-build/src/registry.rs +++ b/crates/stellar-registry-build/src/registry.rs @@ -8,13 +8,6 @@ use crate::{ pub struct Registry(Contract); -impl name::Prefixed { - /// Resolve the (sub)registry this name's channel points at. - pub async fn registry(&self, config: &config::Args) -> Result { - Registry::from_named_registry(config, self).await - } -} - impl Registry { pub async fn from_named_registry( config: &config::Args, diff --git a/crates/stellar-registry-cli/Cargo.toml b/crates/stellar-registry-cli/Cargo.toml index 84a1736..c69daee 100644 --- a/crates/stellar-registry-cli/Cargo.toml +++ b/crates/stellar-registry-cli/Cargo.toml @@ -32,7 +32,10 @@ clap = { workspace = true, features = [ "string", ] } stellar-cli = { workspace = true, default-features = false, features = [] } -stellar-registry-build = { path = "../stellar-registry-build", version = "0.0.10" } +# Explicit features = ["cli"] rather than relying on default features: the +# workspace dep pins default-features = false (for the proc-macro), which drops +# build's default `cli` feature workspace-wide unless a consumer re-requests it. +stellar-registry-build = { path = "../stellar-registry-build", version = "0.0.9", features = ["cli"] } soroban-spec-tools = { workspace = true } diff --git a/crates/stellar-registry-cli/src/commands/create_alias.rs b/crates/stellar-registry-cli/src/commands/create_alias.rs index 302a8b6..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::name::Prefixed; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use stellar_strkey::Contract; use crate::commands::global; diff --git a/crates/stellar-registry-cli/src/commands/current_version.rs b/crates/stellar-registry-cli/src/commands/current_version.rs index fcfd42d..653ccd8 100644 --- a/crates/stellar-registry-cli/src/commands/current_version.rs +++ b/crates/stellar-registry-cli/src/commands/current_version.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::name::Prefixed; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; diff --git a/crates/stellar-registry-cli/src/commands/deploy.rs b/crates/stellar-registry-cli/src/commands/deploy.rs index f35bdbb..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::{name::Prefixed, registry::Registry}; +use stellar_registry_build::{ + name::{Prefixed, RegistryAccess}, + registry::Registry, +}; use crate::commands::global; diff --git a/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs b/crates/stellar-registry-cli/src/commands/deploy_unnamed.rs index 589beff..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::{name::Prefixed, registry::Registry}; +use stellar_registry_build::{ + name::{Prefixed, RegistryAccess}, + registry::Registry, +}; use crate::commands::global; diff --git a/crates/stellar-registry-cli/src/commands/download.rs b/crates/stellar-registry-cli/src/commands/download.rs index 2fe0d52..c39549e 100644 --- a/crates/stellar-registry-cli/src/commands/download.rs +++ b/crates/stellar-registry-cli/src/commands/download.rs @@ -2,7 +2,7 @@ use std::{io::Write, path::PathBuf}; use clap::Parser; use stellar_cli::{commands::contract::invoke, xdr}; -use stellar_registry_build::name::Prefixed; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; 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 ba6fd5b..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::name::Prefixed; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use stellar_strkey::Contract; use crate::commands::global; diff --git a/crates/stellar-registry-cli/src/commands/fetch_hash.rs b/crates/stellar-registry-cli/src/commands/fetch_hash.rs index 4986dd8..b59aba4 100644 --- a/crates/stellar-registry-cli/src/commands/fetch_hash.rs +++ b/crates/stellar-registry-cli/src/commands/fetch_hash.rs @@ -1,6 +1,6 @@ use clap::Parser; use stellar_cli::commands::contract::invoke; -use stellar_registry_build::name::Prefixed; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; diff --git a/crates/stellar-registry-cli/src/commands/upgrade.rs b/crates/stellar-registry-cli/src/commands/upgrade.rs index cd941d2..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::name::Prefixed; +use stellar_registry_build::name::{Prefixed, RegistryAccess}; use crate::commands::global; diff --git a/crates/stellar-registry-name/Cargo.toml b/crates/stellar-registry-name/Cargo.toml new file mode 100644 index 0000000..4a28231 --- /dev/null +++ b/crates/stellar-registry-name/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "stellar-registry-name" +version = "0.0.1" +edition = "2024" +description = "A library defining names used for the 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 = "1.0.28" + +[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..06b1a06 --- /dev/null +++ b/crates/stellar-registry-name/README.md @@ -0,0 +1,3 @@ +# stellar-registry-name + +Core types for a dealing with registry names. diff --git a/crates/stellar-registry-name/src/common.rs b/crates/stellar-registry-name/src/common.rs new file mode 100644 index 0000000..f381dbc --- /dev/null +++ b/crates/stellar-registry-name/src/common.rs @@ -0,0 +1,25 @@ +/// 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.chars() + .map(|c| { + if c == '_' { + '-' + } else { + c.to_ascii_lowercase() + } + }) + .collect() +} + +#[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..313bef8 --- /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: `-` → `_`. + #[must_use] + pub fn mod_name(&self) -> String { + self.name.replace('-', "_") + } + + /// 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(_) + )); + } +} From 464f6a3e07802cd0a340476b565a3da310ce360d Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Wed, 22 Jul 2026 22:19:09 +0200 Subject: [PATCH 14/31] chore: remove leftover code --- .../src/name/prefixed.rs | 168 ------------------ .../src/name/versioned.rs | 137 -------------- 2 files changed, 305 deletions(-) delete mode 100644 crates/stellar-registry-build/src/name/prefixed.rs delete mode 100644 crates/stellar-registry-build/src/name/versioned.rs diff --git a/crates/stellar-registry-build/src/name/prefixed.rs b/crates/stellar-registry-build/src/name/prefixed.rs deleted file mode 100644 index 313bef8..0000000 --- a/crates/stellar-registry-build/src/name/prefixed.rs +++ /dev/null @@ -1,168 +0,0 @@ -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: `-` → `_`. - #[must_use] - pub fn mod_name(&self) -> String { - self.name.replace('-', "_") - } - - /// 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-build/src/name/versioned.rs b/crates/stellar-registry-build/src/name/versioned.rs deleted file mode 100644 index 6beec98..0000000 --- a/crates/stellar-registry-build/src/name/versioned.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::{fmt::Display, str::FromStr}; - -use super::{Error, 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(_) - )); - } -} From 30a95a38dac5cebc009db5e30955e94d53c0d8d2 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Wed, 22 Jul 2026 22:39:00 +0200 Subject: [PATCH 15/31] fix: actually remove cli feature and clean up --- Cargo.lock | 2 +- Cargo.toml | 2 -- crates/stellar-registry-build/Cargo.toml | 23 ++++--------------- crates/stellar-registry-build/src/lib.rs | 12 ++++------ crates/stellar-registry-build/src/name.rs | 2 -- crates/stellar-registry-cli/Cargo.toml | 5 +--- crates/stellar-registry-macro/Cargo.toml | 6 ++--- crates/stellar-registry-macro/src/contract.rs | 2 +- .../src/contract_client.rs | 2 +- crates/stellar-registry-macro/src/util.rs | 4 ++-- 10 files changed, 18 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c01691..ce51929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4304,7 +4304,7 @@ dependencies = [ "quote", "sha2 0.10.9", "stellar-build", - "stellar-registry-build", + "stellar-registry-name", "stellar-strkey 0.0.16", "stellar-xdr", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index 984ea7e..c9f8a2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,6 @@ repository = "https://github.com/stellar-registry/cli" 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" } -# default-features = false so the proc-macro crate gets only the light `name` -# module; network-facing consumers opt back in with features = ["cli"]. stellar-registry-build = { path = "crates/stellar-registry-build", version = "0.0.9" } stellar-registry-name = { path = "crates/stellar-registry-name", version = "0.0.1" } diff --git a/crates/stellar-registry-build/Cargo.toml b/crates/stellar-registry-build/Cargo.toml index c1a71b1..acbc8b7 100644 --- a/crates/stellar-registry-build/Cargo.toml +++ b/crates/stellar-registry-build/Cargo.toml @@ -12,27 +12,14 @@ crate-type = ["rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[features] -default = ["cli"] -# Network-facing registry access (contract, registry, error modules). Off, the -# crate is just the dependency-light `name` module — what proc-macro consumers -# want, so they don't drag the stellar-cli stack into every contract build. -cli = [ - "dep:sha2", - "dep:soroban-rpc", - "dep:stellar-build", - "dep:stellar-cli", - "dep:stellar-strkey", -] - [dependencies] -stellar-cli = { workspace = true, optional = true, default-features = false, features = [] } -stellar-build = { workspace = true, optional = true } +stellar-cli = { workspace = true, default-features = false, features = [] } +stellar-build = { workspace = true } stellar-registry-name = { workspace = true } -soroban-rpc = { workspace = true, optional = true } -stellar-strkey = { workspace = true, optional = true } -sha2 = { workspace = true, optional = true } +soroban-rpc = { workspace = true } +stellar-strkey = { workspace = true } +sha2 = { workspace = true } thiserror = "2.0.17" semver = "1.0.28" diff --git a/crates/stellar-registry-build/src/lib.rs b/crates/stellar-registry-build/src/lib.rs index 88bf804..00602e7 100644 --- a/crates/stellar-registry-build/src/lib.rs +++ b/crates/stellar-registry-build/src/lib.rs @@ -1,18 +1,14 @@ //! Registry interaction at build time. //! -//! The `name` module (typed registry names) is always available and -//! dependency-light so proc-macro crates can use it. Everything that talks to -//! the network — `contract`, `registry`, `error` — sits behind the default -//! `cli` feature, which pulls in the full stellar-cli stack. +//! 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). -#[cfg(feature = "cli")] pub mod contract; -#[cfg(feature = "cli")] pub mod error; -#[cfg(feature = "cli")] pub mod registry; pub mod name; -#[cfg(feature = "cli")] pub use error::Error; diff --git a/crates/stellar-registry-build/src/name.rs b/crates/stellar-registry-build/src/name.rs index 2f2e05d..2f7c984 100644 --- a/crates/stellar-registry-build/src/name.rs +++ b/crates/stellar-registry-build/src/name.rs @@ -1,6 +1,5 @@ pub use stellar_registry_name::*; -#[cfg(feature = "cli")] mod cli { use stellar_cli::config; @@ -20,5 +19,4 @@ mod cli { } } -#[cfg(feature = "cli")] pub use cli::RegistryAccess; diff --git a/crates/stellar-registry-cli/Cargo.toml b/crates/stellar-registry-cli/Cargo.toml index c69daee..1a8cac9 100644 --- a/crates/stellar-registry-cli/Cargo.toml +++ b/crates/stellar-registry-cli/Cargo.toml @@ -32,10 +32,7 @@ clap = { workspace = true, features = [ "string", ] } stellar-cli = { workspace = true, default-features = false, features = [] } -# Explicit features = ["cli"] rather than relying on default features: the -# workspace dep pins default-features = false (for the proc-macro), which drops -# build's default `cli` feature workspace-wide unless a consumer re-requests it. -stellar-registry-build = { path = "../stellar-registry-build", version = "0.0.9", features = ["cli"] } +stellar-registry-build = { path = "../stellar-registry-build", version = "0.0.9" } soroban-spec-tools = { workspace = true } diff --git a/crates/stellar-registry-macro/Cargo.toml b/crates/stellar-registry-macro/Cargo.toml index 52d0441..5a09f80 100644 --- a/crates/stellar-registry-macro/Cargo.toml +++ b/crates/stellar-registry-macro/Cargo.toml @@ -14,9 +14,9 @@ proc-macro2 = { workspace = true } quote = { workspace = true } syn = { workspace = true } stellar-build = { workspace = true } -# name types only — the workspace pins default-features = false so this -# proc-macro never drags the stellar-cli stack into consumer contract builds. -stellar-registry-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 } diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index e9714d2..695fb5e 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -10,7 +10,7 @@ use syn::{ parse::{Parse, ParseStream}, }; -use stellar_registry_build::name::Prefixed; +use stellar_registry_name::Prefixed; use crate::util::{Name, explorer_url, manifest, mod_ident, network_name}; diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs index ec65f23..56952ed 100644 --- a/crates/stellar-registry-macro/src/contract_client.rs +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -8,7 +8,7 @@ use syn::{ parse::{Parse, ParseStream, Result}, }; -use stellar_registry_build::name::Versioned; +use stellar_registry_name::Versioned; use crate::util::{Name, explorer_url, manifest, mod_ident, network_name}; diff --git a/crates/stellar-registry-macro/src/util.rs b/crates/stellar-registry-macro/src/util.rs index 252b49f..906511f 100644 --- a/crates/stellar-registry-macro/src/util.rs +++ b/crates/stellar-registry-macro/src/util.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use proc_macro2::Span; -use stellar_registry_build::name; +use stellar_registry_name as name; use syn::{ Ident, LitStr, parse::{Parse, ParseStream}, @@ -113,7 +113,7 @@ impl ProcMacroWrapper for syn::Result { mod tests { use super::*; use quote::quote; - use stellar_registry_build::name::{Prefixed, Versioned}; + use stellar_registry_name::{Prefixed, Versioned}; fn name(tokens: proc_macro2::TokenStream) -> Name { syn::parse2(tokens).unwrap() From ff553bd4e7135244813b604af5ff6f8c5ae413d3 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:26:23 -0400 Subject: [PATCH 16/31] docs: desc & readme for stellar-registry-name --- crates/stellar-registry-name/Cargo.toml | 2 +- crates/stellar-registry-name/README.md | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/stellar-registry-name/Cargo.toml b/crates/stellar-registry-name/Cargo.toml index 4a28231..86d307c 100644 --- a/crates/stellar-registry-name/Cargo.toml +++ b/crates/stellar-registry-name/Cargo.toml @@ -2,7 +2,7 @@ name = "stellar-registry-name" version = "0.0.1" edition = "2024" -description = "A library defining names used for the registry" +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" diff --git a/crates/stellar-registry-name/README.md b/crates/stellar-registry-name/README.md index 06b1a06..9fa4284 100644 --- a/crates/stellar-registry-name/README.md +++ b/crates/stellar-registry-name/README.md @@ -1,3 +1,18 @@ # stellar-registry-name -Core types for a dealing with registry names. +_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. From 3243f50d533548013766d047b47409522eea664e Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:28:04 -0400 Subject: [PATCH 17/31] chore: rm extraneous cargo.toml spaces --- crates/stellar-registry-name/Cargo.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/stellar-registry-name/Cargo.toml b/crates/stellar-registry-name/Cargo.toml index 86d307c..79bbb1a 100644 --- a/crates/stellar-registry-name/Cargo.toml +++ b/crates/stellar-registry-name/Cargo.toml @@ -6,11 +6,9 @@ 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 = "1.0.28" From 8a2376232af063e3b0261545c9f35d065e5c37ec Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:59:02 -0400 Subject: [PATCH 18/31] docs: missing stellar-registry-macro docs --- CLAUDE.md | 6 ++++-- Cargo.toml | 3 +-- README.md | 5 ++++- crates/stellar-registry-macro/Cargo.toml | 2 +- crates/stellar-registry-macro/README.md | 6 ++++++ 5 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 crates/stellar-registry-macro/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 0bfac6e..1d40e81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | Re-exports the `import_contract!`, `import_contract_client!`, and `import_asset!` macros from `stellar-registry-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 @@ -60,6 +63,5 @@ Note: the `justfile` still carries some recipes from the monorepo. Prefer the `c This repo's crates depend on, from crates.io: - `stellar-build` — published from `stellar-scaffold/cli` -- `stellar-scaffold-test` — pulled via git from `stellar-scaffold/cli` (it is `publish = false`); used in tests only 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.toml b/Cargo.toml index c9f8a2e..1aabb50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,7 @@ stellar-registry-macro = { path = "crates/stellar-registry-macro", version = "0. 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-cli = { version = "27.0.0", package = "soroban-cli", default-features = false } diff --git a/README.md b/README.md index 67424ea..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) | The `import_contract!`, `import_contract_client!`, and `import_asset!` macros | +| [`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-macro/Cargo.toml b/crates/stellar-registry-macro/Cargo.toml index 5a09f80..65b5661 100644 --- a/crates/stellar-registry-macro/Cargo.toml +++ b/crates/stellar-registry-macro/Cargo.toml @@ -2,7 +2,7 @@ name = "stellar-registry-macro" version = "0.0.1" edition = "2024" -description = "The import_contract! macro for the Stellar Registry" +description = "Macro crate defining the proc-macros that get re-exported by stellar-registry" license = "Apache-2.0" repository.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 From 1e45de22b40765f773b1ff987a0d68ce093bf7c5 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 23 Jul 2026 16:33:02 -0400 Subject: [PATCH 19/31] fix: mod_name should be lowercase Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com> --- crates/stellar-registry-name/src/prefixed.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/stellar-registry-name/src/prefixed.rs b/crates/stellar-registry-name/src/prefixed.rs index 313bef8..00b12e2 100644 --- a/crates/stellar-registry-name/src/prefixed.rs +++ b/crates/stellar-registry-name/src/prefixed.rs @@ -61,10 +61,10 @@ impl Prefixed { self.channel.as_deref() } - /// Rust module identifier derived from the name: `-` → `_`. + /// Rust module identifier derived from the name: `-` → `_` and chars to lowercase. #[must_use] pub fn mod_name(&self) -> String { - self.name.replace('-', "_") + self.canonical_name() } /// Canonical on-chain form of the bare name (see [`super::canonicalize`]). From e477aee10c7d6a1eff0504de2cbb190ab3d973ca Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:57:57 -0400 Subject: [PATCH 20/31] docs: registry-macro/util#Name can be for a wasm --- crates/stellar-registry-macro/src/util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/stellar-registry-macro/src/util.rs b/crates/stellar-registry-macro/src/util.rs index 906511f..2b431b5 100644 --- a/crates/stellar-registry-macro/src/util.rs +++ b/crates/stellar-registry-macro/src/util.rs @@ -18,7 +18,7 @@ pub(crate) fn manifest() -> syn::Result { Ok(PathBuf::from(dir).join("Cargo.toml")) } -/// A contract name argument: a bare ident (`registry`) or a string literal +/// 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), From 0f4a8947a43619edf1dd90e07a06f0385b5fd0bb Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:09:07 -0400 Subject: [PATCH 21/31] fix: link to /wasms for import_contract_client --- crates/stellar-registry-macro/src/contract.rs | 2 +- .../stellar-registry-macro/src/contract_client.rs | 2 +- crates/stellar-registry-macro/src/util.rs | 14 ++++---------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index 695fb5e..a73dca4 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -81,7 +81,7 @@ fn wasm_is_stale(previously_cached_id: Option<&str>, address: &str, no_registry: /// 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}\n")) + .map(|url| format!("- Check that you got the name right: {url}/contracts\n")) .unwrap_or_default(); format!( "{name_check}\ diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs index 56952ed..4c2fcb5 100644 --- a/crates/stellar-registry-macro/src/contract_client.rs +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -189,7 +189,7 @@ fn download_from_registry( 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}"), + |url| format!("\n1. check that you got the name right: {url}/wasms"), ); let local_path = local_path.display().to_string(); Err(syn::Error::new( diff --git a/crates/stellar-registry-macro/src/util.rs b/crates/stellar-registry-macro/src/util.rs index 2b431b5..bc98ba6 100644 --- a/crates/stellar-registry-macro/src/util.rs +++ b/crates/stellar-registry-macro/src/util.rs @@ -91,8 +91,8 @@ pub(crate) fn network_name() -> String { /// 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/contracts"), - "mainnet" => Some("https://stellar.rgstry.xyz/contracts"), + "testnet" => Some("https://testnet.rgstry.xyz"), + "mainnet" => Some("https://stellar.rgstry.xyz"), _ => None, } } @@ -182,14 +182,8 @@ mod tests { #[test] fn explorer_urls() { - assert_eq!( - explorer_url("testnet"), - Some("https://testnet.rgstry.xyz/contracts") - ); - assert_eq!( - explorer_url("mainnet"), - Some("https://stellar.rgstry.xyz/contracts") - ); + 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); } From 385783dec1daf6afd788f4a75c3100203127604e Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:35:21 -0400 Subject: [PATCH 22/31] docs: registry-macro doc comments - Remove stale-by-the-time-it-lands parenthetical about needing to be on the latest Registry CLI - Correct occurrences of "contract" when we meant "wasm" --- crates/stellar-registry-macro/src/lib.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index fef3eba..2a11f44 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -31,10 +31,9 @@ use util::ProcMacroWrapper as _; /// - **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 -/// (with an up-to-date `stellar-registry-cli` plugin), and a cached id is -/// deliberately ignored while online so a contract flagged after the first -/// build cannot slip through a stale cache. +/// 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 @@ -54,7 +53,7 @@ pub fn import_contract(input: TokenStream) -> TokenStream { /// `target` directory if present, otherwise downloaded from the registry. /// /// ```ignore -/// // Workspace contracts or registry names without hyphens: +/// // Workspace wasms or registry names without hyphens: /// import_contract_client!(registry); /// /// // Hyphenated or channel-prefixed registry names: From 005309705e1a95a73daac2be2415fc34f96f0dca Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:42:07 -0400 Subject: [PATCH 23/31] docs: `stellar-registry-cli` not `stellar` CLI --- crates/stellar-registry-macro/src/contract.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index a73dca4..f7d8616 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -117,12 +117,12 @@ fn resolve_address( validate_contract_id(&fetch()?) } -/// Shell out to the `stellar` 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. +/// 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"]) From 0b2f6b7aef14982b47c4d1afbd7fe69c24dacbd6 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:54:58 -0400 Subject: [PATCH 24/31] chore: use cleaner var check Mirror the `as_deref() == Ok("1")` syntax used by sibling `contract.rs` --- crates/stellar-registry-macro/src/contract_client.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs index 4c2fcb5..4526db1 100644 --- a/crates/stellar-registry-macro/src/contract_client.rs +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -93,9 +93,7 @@ fn resolve_wasm_path(wasm: &Versioned, mod_name: &Ident) -> Result { } // 2. If STELLAR_NO_REGISTRY set to 1, error - if let Ok(v) = env::var("STELLAR_NO_REGISTRY") - && &v == "1" - { + if env::var("STELLAR_NO_REGISTRY").as_deref() == Ok("1") { return Err(syn::Error::new( span, format!( From ba8db4c352363b453ba206f1814ddd2dcaef54f1 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Fri, 24 Jul 2026 15:08:59 -0400 Subject: [PATCH 25/31] fix: use display directly Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com> --- crates/stellar-registry-macro/src/contract.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index f7d8616..c3540b4 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -87,11 +87,9 @@ fn resolution_help(lookup: &Prefixed, id_path: &Path, wasm_path: &Path) -> Strin "{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 \ + - Set STELLAR_NO_REGISTRY=1 to prevent network calls. You will need to create {id_path} and \ + {wasm_path} 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(), ) } From 1cca728c6e71b1f655a0d5796be510e71c07e240 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Fri, 24 Jul 2026 15:09:24 -0400 Subject: [PATCH 26/31] fix: another direct display Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com> --- crates/stellar-registry-macro/src/contract.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index c3540b4..061a4be 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -260,9 +260,9 @@ pub(crate) fn import_contract( 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(), + fetches it), or run `stellar contract fetch --id {address} \ + --out-file {wasm_path}` yourself.", + ))); } fetch_wasm(&address, &wasm_path).map_err(err)?; From dcfb5e6ceb2a2f1210ba34be36f7f221f5bcdf4a Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:01:24 -0400 Subject: [PATCH 27/31] build: mv semver and thiserror to root cargo.toml --- Cargo.toml | 1 + crates/stellar-registry-build/Cargo.toml | 4 ++-- crates/stellar-registry-name/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1aabb50..a6db099 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ 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/crates/stellar-registry-build/Cargo.toml b/crates/stellar-registry-build/Cargo.toml index acbc8b7..aed5a7c 100644 --- a/crates/stellar-registry-build/Cargo.toml +++ b/crates/stellar-registry-build/Cargo.toml @@ -21,8 +21,8 @@ soroban-rpc = { workspace = true } stellar-strkey = { workspace = true } sha2 = { workspace = true } -thiserror = "2.0.17" -semver = "1.0.28" +thiserror = { workspace = true } +semver = { workspace = true } [dev-dependencies] expect-test = "1.5" diff --git a/crates/stellar-registry-name/Cargo.toml b/crates/stellar-registry-name/Cargo.toml index 79bbb1a..722b467 100644 --- a/crates/stellar-registry-name/Cargo.toml +++ b/crates/stellar-registry-name/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["rlib"] [dependencies] thiserror = { workspace = true } -semver = "1.0.28" +semver = { workspace = true } [dev-dependencies] expect-test = "1.5" From 0aff8492ce222d6487813c3735284f63b12e0d16 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:19:33 -0400 Subject: [PATCH 28/31] build: rm extraneous line --- crates/stellar-registry-macro/src/contract.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index 061a4be..f720726 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -262,7 +262,6 @@ pub(crate) fn import_contract( "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 {wasm_path}` yourself.", - ))); } fetch_wasm(&address, &wasm_path).map_err(err)?; From 5518e09b64f19c8c325eeefdef76dff2d65a6199 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:35:16 -0400 Subject: [PATCH 29/31] docs: stellar-registry readme --- crates/stellar-registry/README.md | 48 +++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) 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. From 44f0ceb96f694d2ccbde0be7e4b4b8c7e283b8e1 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:29:25 -0400 Subject: [PATCH 30/31] fix: Path doesn't implement Display Can't use the variables directly in the `format!`, need to call `.display()` explicitly. ``` error[E0277]: `std::path::Path` doesn't implement `std::fmt::Display` --> crates/stellar-registry-macro/src/contract.rs:90:88 ``` But in `contract_client.rs` we want to show the full path, not just the `wasm.name()`. Since `Versioned` implements `Display`, we can shorten this to put `wasm` in the `format!`. --- crates/stellar-registry-macro/src/contract.rs | 9 ++++++--- crates/stellar-registry-macro/src/contract_client.rs | 3 +-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index f720726..bef650d 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -87,9 +87,11 @@ fn resolution_help(lookup: &Prefixed, id_path: &Path, wasm_path: &Path) -> Strin "{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_path} and \ - {wasm_path} yourself, perhaps using `stellar registry fetch-contract-id` for the id and \ + - 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(), ) } @@ -261,7 +263,8 @@ pub(crate) fn import_contract( 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 {wasm_path}` yourself.", + --out-file {path}` yourself.", + path = wasm_path.display(), ))); } fetch_wasm(&address, &wasm_path).map_err(err)?; diff --git a/crates/stellar-registry-macro/src/contract_client.rs b/crates/stellar-registry-macro/src/contract_client.rs index 4526db1..cf8e355 100644 --- a/crates/stellar-registry-macro/src/contract_client.rs +++ b/crates/stellar-registry-macro/src/contract_client.rs @@ -98,8 +98,7 @@ fn resolve_wasm_path(wasm: &Versioned, mod_name: &Ident) -> Result { span, format!( "No local wasm found and STELLAR_NO_REGISTRY=1 so not checking Registry. \ - Download manually with `stellar registry download {}`", - wasm.name() + Download manually with `stellar registry download \"{wasm}\"`", ), )); } From e484fadd44468822f88846566ba9bf8250a86c83 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski <221614+chadoh@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:57:13 -0400 Subject: [PATCH 31/31] fix: mod_name and canonical_name are opposites `canonical_name` returns the name as it should be formatted _on chain_, which means _no underscores_. It replaces underscores with hyphens. `mod_name` returns a name appropriate for use as a Rust module, which means _no hyphens_. It replaces hyphens with underscores. This also adds a test to ensure that `mod_name` lowercases the input. This shouldn't happen because the canonical on-chain form of the name is not allowed to have capital letters. --- crates/stellar-registry-macro/src/util.rs | 7 +++++++ crates/stellar-registry-name/src/common.rs | 10 +--------- crates/stellar-registry-name/src/prefixed.rs | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/stellar-registry-macro/src/util.rs b/crates/stellar-registry-macro/src/util.rs index bc98ba6..dda04e0 100644 --- a/crates/stellar-registry-macro/src/util.rs +++ b/crates/stellar-registry-macro/src/util.rs @@ -167,6 +167,13 @@ mod tests { 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(); diff --git a/crates/stellar-registry-name/src/common.rs b/crates/stellar-registry-name/src/common.rs index f381dbc..428212f 100644 --- a/crates/stellar-registry-name/src/common.rs +++ b/crates/stellar-registry-name/src/common.rs @@ -2,15 +2,7 @@ /// The registry contract stores names in this form. #[must_use] pub fn canonicalize(name: &str) -> String { - name.chars() - .map(|c| { - if c == '_' { - '-' - } else { - c.to_ascii_lowercase() - } - }) - .collect() + name.replace('_', "-").to_ascii_lowercase() } #[cfg(test)] diff --git a/crates/stellar-registry-name/src/prefixed.rs b/crates/stellar-registry-name/src/prefixed.rs index 00b12e2..09b3a1b 100644 --- a/crates/stellar-registry-name/src/prefixed.rs +++ b/crates/stellar-registry-name/src/prefixed.rs @@ -64,7 +64,7 @@ impl Prefixed { /// Rust module identifier derived from the name: `-` → `_` and chars to lowercase. #[must_use] pub fn mod_name(&self) -> String { - self.canonical_name() + self.name.replace('-', "_").to_ascii_lowercase() } /// Canonical on-chain form of the bare name (see [`super::canonicalize`]).