Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
71fe052
feat: add register_import! for testing import_contract! callers
pselle Oct 13, 2026
e22d595
Merge branch 'main' into feat/register-import
pselle Oct 13, 2026
13319f2
build(deps): move hello_caller fixture to soroban-sdk 28
pselle Oct 13, 2026
c7bc241
Apply suggestion from @chadoh
pselle Oct 16, 2026
e2a29c0
docs: show running import_contract! tests against testnet first
pselle Oct 16, 2026
e9f6198
feat: named constructor args for register_import!
pselle Oct 16, 2026
919299d
Merge branch 'main' into feat/register-import
pselle Oct 16, 2026
d15e078
test: add a testnet recipe for import_contract! + register_import!
pselle Oct 16, 2026
9f64340
test: slim down the testnet fixture
pselle Oct 16, 2026
1369dfd
test: point the testnet fixture at unverified/my-token
pselle Oct 16, 2026
c429c66
Merge branch 'main' into feat/register-import
pselle Oct 26, 2026
2e49a32
docs: drop a stray doc comment on constructor_params
pselle Nov 3, 2026
9cc0e57
feat: add stellar registry clone-contract
pselle Nov 3, 2026
380f171
fix: look up clone-contract's source on the canonical registry
pselle Nov 3, 2026
cc009b4
refactor: fold clone-contract into deploy --from-network
pselle Nov 3, 2026
7edea53
fix: add the deploy from_network module missed from the last commit
pselle Nov 3, 2026
089409e
feat: explain a missing registry on the local network
pselle Nov 3, 2026
243a2dd
revert: take deploy --from-network back out of this PR
pselle Nov 4, 2026
9da97a0
feat: explain a missing registry on the local network
pselle Nov 4, 2026
8341323
refactor: address review on register_import!
pselle Nov 4, 2026
6604fd4
Merge branch 'main' into feat/register-import
pselle Nov 4, 2026
8305ee8
docs: tidy register_import! docs and messages
pselle Nov 5, 2026
a782636
chore: update hello_caller's lockfile to match main
pselle Nov 5, 2026
9ee15b1
docs: link the justfile with blob/, and write import_asset! in CLAUDE.md
pselle Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ jobs:
run: just clippy-test
- name: Unit Tests
run: just test
- name: Fixture tests (import_contract! + register_import!)
run: just test-register-import
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ 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`, and `account` (`account register`, `account preauthorize`, ...) |
| `stellar-registry-build` | Library for interacting with the registry at build time |
| `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-macro` | Macro crate defining procedural macros `import_contract!`, `import_contract_client!`, `import_asset!`, and the test helper `register_import!`. 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`. Its `testutils` feature adds `testutils::register_import!` for testing contracts that use `import_contract!`. 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 |

Expand All @@ -57,6 +57,7 @@ Note: the `justfile` still carries some recipes from the monorepo. Prefer the `c

- Unit tests run without external dependencies.
- Integration tests require a local Stellar RPC (Docker `stellar/quickstart`) and the `integration-tests` feature flag.
- `just test-register-import` runs the `hello_caller` fixture, which is excluded from the workspace because `import_contract!` needs seeded cache files to compile.
- Command tests use `stellar_registry_test::RegistryTest`, defined in local crate `crates/stellar-registry-test`

## Cross-repo dependencies
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
resolver = "2"
default-members = ["crates/*"]
members = ["crates/*", "crates/stellar-registry-test/fixtures/contracts/*"]
# Needs seeded `import_contract!` cache files to compile; see its Cargo.toml.
exclude = [
"crates/stellar-registry-test/fixtures/contracts/hello_caller",
]

[workspace.package]
repository = "https://github.com/stellar-registry/cli"
Expand All @@ -21,6 +25,7 @@ stellar-cli = { version = "28.1.0", package = "soroban-cli", default-features =
soroban-rpc = { package = "stellar-rpc-client", version = "28.0.0" }
soroban-sdk = { version = "28.0.0" }
soroban-spec-tools = "28.1.0"
soroban-spec = "28.0.0"

admin-sep = "0.28.1"
stellar-xdr = "=30.0.0"
Expand Down
8 changes: 8 additions & 0 deletions crates/stellar-registry-build/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,12 @@ pub enum Error {
Rpc(#[from] soroban_rpc::Error),
#[error(transparent)]
Xdr(#[from] stellar_cli::xdr::Error),
#[error(
"no Stellar Registry is deployed on the local network (looked for one at \
{id}{from_env}). Deploy the registry contract on local and set \
STELLAR_REGISTRY_CONTRACT_ID to its id, or set up local development with Stellar \
Scaffold.",
from_env = if *.from_env { " (from STELLAR_REGISTRY_CONTRACT_ID)" } else { "" }
)]
NoLocalRegistry { id: String, from_env: bool },
}
26 changes: 25 additions & 1 deletion crates/stellar-registry-build/src/registry.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use stellar_cli::config;
use stellar_cli::config::{self, network::passphrase};

use crate::{
Error,
Expand All @@ -18,6 +18,7 @@ impl Registry {

pub async fn new(config: &config::Args, name: Option<&str>) -> Result<Self, Error> {
let contract = Self::verified(config)?;
contract.ensure_local_root_exists(config).await?;
Ok(if let Some(name) = name {
if let Ok(contract_id) = name.parse() {
Registry(Contract::new(contract_id, config))
Expand Down Expand Up @@ -190,6 +191,29 @@ impl Registry {
)
}

/// On a local network nothing is at the canonical root address unless
/// someone deployed a registry there, and every command would otherwise
/// fail with a bare "contract not found". Elsewhere the root exists.
async fn ensure_local_root_exists(&self, config: &config::Args) -> Result<(), Error> {
use stellar_cli::xdr;
if config.get_network()?.network_passphrase != passphrase::LOCAL {
return Ok(());
}
let instance = self
.contract_data(
xdr::ScVal::LedgerKeyContractInstance,
xdr::ContractDataDurability::Persistent,
)
.await?;
if instance.is_some() {
return Ok(());
}
Err(Error::NoLocalRegistry {
id: format!("{}", self.0.id()),
from_env: std::env::var("STELLAR_REGISTRY_CONTRACT_ID").is_ok(),
})
}

pub fn as_contract(&self) -> &Contract {
&self.0
}
Expand Down
22 changes: 22 additions & 0 deletions crates/stellar-registry-cli/src/commands/fetch_contract_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,28 @@ mod tests {
assert!(!contract_id.to_string().is_empty());
}

#[tokio::test]
async fn explains_a_missing_local_registry() {
let registry = RegistryTest::new().await;
// A valid contract id with nothing deployed at it.
let missing = "CBESJIMX7J53SWJGJ7WQ6QTLJI4S5LPPJNC2BNVD63GIKAYCDTDOO322";
let stderr = registry
.registry_cli("fetch-contract-id")
.env("STELLAR_REGISTRY_CONTRACT_ID", missing)
.arg("hello")
.assert()
.failure()
.get_output()
.stderr
.clone();
let stderr = String::from_utf8_lossy(&stderr);
assert!(
stderr.contains("no Stellar Registry is deployed on the local network"),
"{stderr}"
);
assert!(stderr.contains(missing), "{stderr}");
}

#[tokio::test]
async fn flagged() {
let registry = RegistryTest::new().await;
Expand Down
1 change: 1 addition & 0 deletions crates/stellar-registry-macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ stellar-build = { workspace = true }
stellar-registry-name = { workspace = true }
stellar-strkey = { workspace = true }
stellar-xdr = { workspace = true }
soroban-spec = { workspace = true }

[dev-dependencies]
sha2 = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/stellar-registry-macro/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +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.
The [macro crate] defining `import_contract!`, `import_contract_client!`, `import_asset!`, and the test helper `register_import!`, 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
69 changes: 49 additions & 20 deletions crates/stellar-registry-macro/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ use crate::util::{Name, explorer_url, manifest, mod_ident, network_name, track_n

/// `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,
pub(crate) struct Input {
pub(crate) env: Expr,
pub(crate) name: Name,
}

impl Parse for Input {
Expand Down Expand Up @@ -292,10 +292,26 @@ fn expand_asset_token_client(env: &Expr, asset_xdr: &[u8]) -> proc_macro2::Token
}
}

pub(crate) fn import_contract(
input: proc_macro::TokenStream,
) -> syn::Result<proc_macro2::TokenStream> {
let Input { env, name } = syn::parse(input)?;
/// What a name resolved to at build time.
#[derive(Debug)]
pub(crate) enum Resolved {
/// A Wasm-backed contract: its deployed address and cached Wasm.
Wasm {
mod_ident: Ident,
wasm_path: PathBuf,
address: String,
},
/// A registered name that points at a Stellar Asset Contract. There is
/// no Wasm.
Sac { address: String },
/// Asset syntax: the SAC address is derived at runtime from this XDR.
Asset { xdr: Vec<u8> },
}

/// Resolve `name` for `macro_name!` — shared by `import_contract!` and
/// `register_import!` so a test registers at exactly the address (and with
/// exactly the Wasm) the contract under test was compiled against.
pub(crate) fn resolve(name: &Name, macro_name: &str) -> syn::Result<Resolved> {
let span = name.span();
let err = |msg: String| syn::Error::new(span, msg);

Expand All @@ -304,16 +320,16 @@ pub(crate) fn import_contract(
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)`."
"{macro_name}! does not take a version — a deployed contract has no version \
(got {raw:?}). Use just the contract name, e.g. `{macro_name}!(env, our_dao)`."
)));
}

// Asset syntax (`xlm`, `native`, `"CODE:ISSUER"`) needs no registry
// lookup or cache files: the SAC address is derived at runtime.
if is_asset_syntax(&raw) {
let (asset_xdr, _code) = asset::asset_xdr(&raw).map_err(err)?;
return Ok(expand_asset_token_client(&env, &asset_xdr));
let (xdr, _code) = asset::asset_xdr(&raw).map_err(err)?;
return Ok(Resolved::Asset { xdr });
}

let contract: Prefixed = name.parse_as()?;
Expand Down Expand Up @@ -349,7 +365,7 @@ pub(crate) fn import_contract(
if cached_sac_address.is_some()
&& !wasm_is_stale(cached_sac_address.as_deref(), &address, no_registry)
{
return Ok(expand_token_client(&env, &address));
return Ok(Resolved::Sac { address });
}

// 3. Ensure the deployed contract's wasm is on disk for `contractimport!`,
Expand All @@ -371,19 +387,32 @@ pub(crate) fn import_contract(
// doesn't repeat a fetch that can only fail the same way again.
Err(e) if is_stellar_asset_fetch_error(&e) => {
record_sac_marker(&sac_marker_path, &address);
return Ok(expand_token_client(&env, &address));
return Ok(Resolved::Sac { address });
}
Err(e) => return Err(err(e)),
}
}

// 4. Generate the client from that wasm and bind it to the address.
Ok(expand(
&env,
&mod_ident,
&wasm_path.to_string_lossy(),
&address,
))
Ok(Resolved::Wasm {
mod_ident,
wasm_path,
address,
})
}

pub(crate) fn import_contract(
input: proc_macro::TokenStream,
) -> syn::Result<proc_macro2::TokenStream> {
let Input { env, name } = syn::parse(input)?;
Ok(match resolve(&name, "import_contract")? {
Resolved::Wasm {
mod_ident,
wasm_path,
address,
} => expand(&env, &mod_ident, &wasm_path.to_string_lossy(), &address),
Resolved::Sac { address } => expand_token_client(&env, &address),
Resolved::Asset { xdr } => expand_asset_token_client(&env, &xdr),
})
}

#[cfg(test)]
Expand Down
61 changes: 59 additions & 2 deletions crates/stellar-registry-macro/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
//! 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. Registry
//! names resolve at build time; asset addresses are derived at runtime.
//! Stellar assets (`import_asset!`) as type-safe soroban clients, plus
//! `register_import!` for testing contracts that use `import_contract!`.
//! Registry names resolve at build time; asset addresses are derived at
//! runtime.
extern crate proc_macro;
use proc_macro::TokenStream;

mod asset;
mod contract;
mod contract_client;
mod register;
mod util;

use util::ProcMacroWrapper as _;
Expand Down Expand Up @@ -64,11 +67,65 @@ use util::ProcMacroWrapper as _;
/// resolved address turns out to be a built-in asset contract rather than a
/// wasm-backed one, the same `token::TokenClient` is bound to it instead of
/// generating types from a (nonexistent) fetched wasm.
///
/// # Testing
///
/// Nothing lives at the baked address in a test `Env`; use
/// [`register_import!`] to register the imported contract (or a mock) there.
#[proc_macro]
pub fn import_contract(input: TokenStream) -> TokenStream {
contract::import_contract(input).to_token_stream()
}

/// Register an [`import_contract!`]-ed contract in a test `Env`, at the
/// address `import_contract!` baked in. Evaluates to that `Address`.
///
/// `import_contract!(env, our_dao)` compiles a real on-chain address into
/// your contract, but nothing lives at that address in a sandboxed test
/// `Env`. `register_import!` puts the imported contract there, so the code
/// under test runs unchanged:
///
/// ```ignore
/// use stellar_registry::testutils::register_import;
///
/// let env = Env::default();
/// // The imported contract's own Wasm, as cached by `import_contract!`:
/// register_import!(&env, our_dao);
/// // ...with constructor arguments, by name:
/// register_import!(&env, our_dao, args = { admin, quorum: 7u32 });
/// // ...or a native mock in place of the Wasm:
/// register_import!(&env, our_dao, contract = MockDao);
/// ```
///
/// - `env` is a `&Env`; the name is written exactly as in `import_contract!`
/// and resolved by the same code, so both macros always agree on the
/// address and Wasm (same cache files, same `STELLAR_NO_REGISTRY`
/// handling, same flagged-contract check).
/// - `args = { name: value, .. }` passes constructor arguments by parameter
/// name, read from the imported Wasm's constructor. Values are
/// type-checked against the constructor. Leaving out `args` when the
/// constructor takes parameters is a compile error that lists them. A
/// positional tuple, `args = (..)`, also works.
/// - `contract = <expr>` registers a native `#[contract]` type instead of the
/// Wasm, for when you want to control what the dependency returns. A mock
/// can have its own constructor, so `args` may be given with `contract`, but
/// only as a tuple: named `args` are rejected, since a mock has no spec to
/// name them by.
///
/// Exported as `stellar_registry::testutils::register_import!`, behind
/// `stellar-registry`'s `testutils` feature. The expansion calls
/// `Env::register_at`, so `soroban-sdk`'s `testutils` feature must be enabled
/// too.
///
/// Stellar Asset Contracts — `xlm`/`native`, `"CODE:ISSUER"`, or a registered
/// name that resolves to a SAC — are not supported yet and fail to compile;
/// register a test asset with `Env::register_stellar_asset_contract_v2`
/// instead.
#[proc_macro]
pub fn register_import(input: TokenStream) -> TokenStream {
register::register_import(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.
///
Expand Down
Loading
Loading