diff --git a/CLAUDE.md b/CLAUDE.md index 9b711ed..592a83a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +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. +- `just test-register-import` runs the fixture contracts' own tests, which `just test` skips (it only covers `crates/*`): `xlm_tip_jar`, and `hello_caller`, 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 diff --git a/crates/stellar-registry-macro/src/contract.rs b/crates/stellar-registry-macro/src/contract.rs index 73c2bc8..0c3ad6c 100644 --- a/crates/stellar-registry-macro/src/contract.rs +++ b/crates/stellar-registry-macro/src/contract.rs @@ -451,34 +451,84 @@ pub(crate) fn import_contract( }) } +/// Emit a block expression putting a Stellar Asset Contract test double at +/// `address`. A test `Env` only creates a SAC at the address derived from its +/// asset, so this registers a throwaway one and copies its contract instance +/// (executable, admin, asset info) to `address`. Evaluates to the `Address`. +fn expand_register_sac(env: &Expr, address: &str) -> proc_macro2::TokenStream { + quote! { + { + extern crate std as __std; + use ::soroban_sdk::xdr as __xdr; + let __env: &::soroban_sdk::Env = #env; + let __address = ::soroban_sdk::Address::from_str(__env, #address); + let __admin = + <::soroban_sdk::Address as ::soroban_sdk::testutils::Address>::generate(__env); + let __double = __env.register_stellar_asset_contract_v2(__admin).address(); + let __instance_key = |address: &::soroban_sdk::Address| { + __std::rc::Rc::new(__xdr::LedgerKey::ContractData(__xdr::LedgerKeyContractData { + contract: __xdr::ScAddress::from(address), + key: __xdr::ScVal::LedgerKeyContractInstance, + durability: __xdr::ContractDataDurability::Persistent, + })) + }; + let (__entry, __live_until) = __env + .host() + .get_ledger_entry(&__instance_key(&__double)) + .unwrap() + .unwrap(); + let mut __entry = (*__entry).clone(); + if let __xdr::LedgerEntryData::ContractData(__data) = &mut __entry.data { + __data.contract = __xdr::ScAddress::from(&__address); + } + __env + .host() + .add_ledger_entry( + &__instance_key(&__address), + &__std::rc::Rc::new(__entry), + __live_until, + ) + .unwrap(); + __address + } + } +} + /// Emit a block expression registering a contract at the address -/// `import_contract!` baked in: the imported Wasm, or `contract` (a native -/// mock) when given. Evaluates to the `Address`. +/// `import_contract!` baked in: `contract` (a native mock) when given, +/// otherwise the imported Wasm, or a test asset for a SAC. Evaluates to the +/// `Address`. fn expand_register( input: &RegisterInput, resolved: &Resolved, ) -> syn::Result { - let (wasm_path, address) = match resolved { + let env = &input.env; + let (address, wasm_path) = match resolved { Resolved::Wasm { wasm_path, address, .. - } => (wasm_path.to_string_lossy(), address), - Resolved::Sac { .. } => { - return Err(syn::Error::new( - input.name.span(), - format!( - "register_import! does not support Stellar Asset Contracts yet, and `{}` \ - resolves to one. SAC support is coming; until then, register a test asset \ - yourself with `Env::register_stellar_asset_contract_v2`.", - input.name.raw() - ), - )); + } => (address, Some(wasm_path)), + Resolved::Sac { address } => (address, None), + }; + let contract = match (&input.contract, wasm_path) { + (Some(contract), _) => quote!(#contract), + (None, Some(wasm_path)) => { + let wasm_path = wasm_path.to_string_lossy(); + quote!(::core::include_bytes!(#wasm_path).as_slice()) + } + (None, None) => { + if let Some(args) = &input.args { + return Err(syn::Error::new_spanned( + args, + format!( + "`{}` is a Stellar Asset Contract, which has no constructor; remove \ + `args`, or pass `contract = ..` to register a mock that takes them.", + input.name.raw() + ), + )); + } + return Ok(expand_register_sac(env, address)); } }; - let env = &input.env; - let contract = input.contract.as_ref().map_or_else( - || quote!(::core::include_bytes!(#wasm_path).as_slice()), - |contract| quote!(#contract), - ); let args = input .args .as_ref() @@ -845,19 +895,45 @@ mod register { assert!(out.contains(A), "still at the baked address: {out}"); } - #[test] - fn stellar_asset_contracts_are_a_clear_compile_error() { - let sac = Resolved::Sac { + fn sac() -> Resolved { + Resolved::Sac { address: A.to_string(), - }; - for tokens in [ - quote!(&env, xlm), - quote!(&env, "circle/usdc", contract = MockToken), - ] { - let input: RegisterInput = parse2(tokens).unwrap(); - let msg = expand_register(&input, &sac).unwrap_err().to_string(); - assert!(msg.contains("Stellar Asset Contracts"), "{msg}"); - assert!(msg.contains("SAC support is coming"), "{msg}"); } } + + #[test] + fn stellar_asset_contract_gets_a_test_asset_at_the_baked_address() { + for tokens in [quote!(&env, xlm), quote!(&env, "circle/usdc")] { + let out = expanded(tokens, &sac()); + assert!( + out.contains("register_stellar_asset_contract_v2"), + "stands up a real SAC: {out}" + ); + assert!( + out.contains("add_ledger_entry"), + "copies its instance to the baked address: {out}" + ); + assert!(out.contains(A), "bakes the resolved id: {out}"); + assert!(!out.contains("register_at"), "{out}"); + assert!(!out.contains("include_bytes"), "no wasm exists: {out}"); + } + } + + #[test] + fn stellar_asset_contract_can_be_replaced_by_a_mock() { + let out = expanded( + quote!(&env, xlm, contract = MockToken, args = (7u32,)), + &sac(), + ); + assert!(out.contains("Env :: register_at"), "{out}"); + assert!(out.contains("MockToken , (7u32 ,)"), "{out}"); + assert!(!out.contains("register_stellar_asset_contract_v2"), "{out}"); + } + + #[test] + fn stellar_asset_contract_rejects_constructor_args() { + let input: RegisterInput = parse2(quote!(&env, xlm, args = (&admin,))).unwrap(); + let msg = expand_register(&input, &sac()).unwrap_err().to_string(); + assert!(msg.contains("has no constructor"), "{msg}"); + } } diff --git a/crates/stellar-registry-macro/src/lib.rs b/crates/stellar-registry-macro/src/lib.rs index ad405e2..d8a553e 100644 --- a/crates/stellar-registry-macro/src/lib.rs +++ b/crates/stellar-registry-macro/src/lib.rs @@ -68,7 +68,8 @@ use util::ProcMacroWrapper as _; /// # Testing /// /// Nothing lives at the baked address in a test `Env`; use -/// [`register_import!`] to register the imported contract (or a mock) there. +/// [`register_import!`] to register the imported contract, a test asset for +/// a SAC, or a mock there. #[proc_macro] pub fn import_contract(input: TokenStream) -> TokenStream { contract::import_contract(input).to_token_stream() @@ -109,10 +110,22 @@ pub fn import_contract(input: TokenStream) -> TokenStream { /// `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. +/// # Stellar Asset Contracts (SACs) +/// +/// For `xlm`/`native`, `"CODE:ISSUER"`, or a registered name that resolves to +/// a SAC, there is no Wasm to register. `register_import!` instead puts a +/// test asset (as made by `Env::register_stellar_asset_contract_v2`) at the +/// baked address, with a generated admin so tests can mint: +/// +/// ```ignore +/// env.mock_all_auths(); +/// let xlm = register_import!(&env, xlm); +/// token::StellarAssetClient::new(&env, &xlm).mint(&alice, &1_000); +/// ``` +/// +/// It is a stand-in, not the real asset: its code and issuer are the test +/// asset's, whatever name was imported. `args` is rejected (a SAC has no +/// constructor); `contract = ` works as for any other name. #[proc_macro] pub fn register_import(input: TokenStream) -> TokenStream { contract::register_import(input).to_token_stream() diff --git a/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/Cargo.toml b/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/Cargo.toml index 653a549..ecba05f 100644 --- a/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/Cargo.toml +++ b/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/Cargo.toml @@ -4,8 +4,8 @@ version = "0.0.0" edition = "2024" publish = false -# Demonstrates `import_contract!(env, xlm)` for Stellar Asset Contracts, -# with no wasm/name to register — see src/lib.rs. +# Demonstrates `import_contract!(env, xlm)` for Stellar Asset Contracts, and +# testing it with `register_import!` — see src/lib.rs. [package.metadata.stellar] # Set contract metadata for authors, homepage, and version based on the Cargo.toml package values @@ -21,3 +21,4 @@ stellar-registry = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +stellar-registry = { workspace = true, features = ["testutils"] } diff --git a/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/src/lib.rs b/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/src/lib.rs index 3144ac2..932b3ae 100644 --- a/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/src/lib.rs +++ b/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar/src/lib.rs @@ -1,55 +1,14 @@ #![no_std] //! Demonstrates getting a working Stellar Asset Contract client via -//! `stellar_registry::import_contract!(env, xlm)`, plus the counterpart -//! pattern for exercising it in an in-process unit test. +//! `stellar_registry::import_contract!(env, xlm)`, and unit-testing it with +//! `stellar_registry::testutils::register_import!`. //! //! `import_contract!(env, xlm)` resolves XLM's real, network-specific -//! contract id at *build* time — but that address isn't instantiated in a -//! sandboxed test `Env`, so a unit test can't call through it directly. The -//! `xlm` module below swaps in `Env::register_stellar_asset_contract_v2` (a -//! real SAC test double) under `#[cfg(test)]`, keeping the call site in -//! `Contract` — `xlm::token_client(env)` — identical either way. +//! contract id at *build* time, with no network call and no cache files. +//! That address is empty in a sandboxed test `Env`, so the test below calls +//! `register_import!(&env, xlm)` to put a mintable test asset there. use soroban_sdk::{Address, Env, contract, contractimpl}; -#[cfg(not(test))] -mod xlm { - use soroban_sdk::{Env, token}; - - /// The real "xlm" asset, resolved offline at build time — no network - /// call, no cache files (see `import_contract!`'s SAC/XLM support). - pub fn token_client(env: &Env) -> token::TokenClient<'_> { - stellar_registry::import_contract!(env, xlm) - } -} - -#[cfg(test)] -mod xlm { - use soroban_sdk::{Address, Env, contracttype, testutils::Address as _, token}; - - #[contracttype] - enum DataKey { - Sac, - } - - /// Stand up a real SAC test double in this sandboxed `Env`, and remember - /// its address in `contract_id`'s own instance storage so `token_client` - /// (called from inside the contract, where `env`'s current contract is - /// already `contract_id`) can find it. Returns the SAC's address so tests - /// can mint balances against it directly. - pub fn register(env: &Env, contract_id: &Address) -> Address { - let sac = env.register_stellar_asset_contract_v2(Address::generate(env)); - env.as_contract(contract_id, || { - env.storage().instance().set(&DataKey::Sac, &sac.address()); - }); - sac.address() - } - - pub fn token_client(env: &Env) -> token::TokenClient<'_> { - let address: Address = env.storage().instance().get(&DataKey::Sac).unwrap(); - token::TokenClient::new(env, &address) - } -} - #[contract] pub struct Contract; @@ -58,12 +17,16 @@ impl Contract { /// Move `amount` stroops of XLM from `from` into this contract's balance. pub fn tip(env: &Env, from: &Address, amount: i128) { from.require_auth(); - xlm::token_client(env).transfer(from, env.current_contract_address(), &amount); + stellar_registry::import_contract!(env, xlm).transfer( + from, + env.current_contract_address(), + &amount, + ); } /// This contract's current XLM balance. pub fn balance(env: &Env) -> i128 { - xlm::token_client(env).balance(&env.current_contract_address()) + stellar_registry::import_contract!(env, xlm).balance(&env.current_contract_address()) } } @@ -71,24 +34,24 @@ impl Contract { mod test { use super::*; use soroban_sdk::{testutils::Address as _, token}; + use stellar_registry::testutils::register_import; #[test] fn tip_moves_xlm_into_the_jar() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); - let sac_address = xlm::register(&env, &contract_id); + let xlm = register_import!(&env, xlm); + let client = ContractClient::new(&env, &env.register(Contract, ())); let alice = Address::generate(&env); - token::StellarAssetClient::new(&env, &sac_address).mint(&alice, &1_000_000_000); + token::StellarAssetClient::new(&env, &xlm).mint(&alice, &1_000_000_000); client.tip(&alice, &500); assert_eq!(client.balance(), 500); assert_eq!( - token::TokenClient::new(&env, &sac_address).balance(&alice), + token::TokenClient::new(&env, &xlm).balance(&alice), 1_000_000_000 - 500 ); } diff --git a/crates/stellar-registry/README.md b/crates/stellar-registry/README.md index 61d903f..643ec59 100644 --- a/crates/stellar-registry/README.md +++ b/crates/stellar-registry/README.md @@ -79,7 +79,27 @@ STELLAR_NETWORK=testnet cargo test For tests with no network access, such as in CI, set `STELLAR_NO_REGISTRY=1` and put those two files in place before building (from `stellar registry fetch-contract-id` and `stellar contract fetch`, or from copies you commit). The [`hello_caller` fixture](https://github.com/stellar-registry/cli/tree/main/crates/stellar-registry-test/fixtures/contracts/hello_caller) does this, run by `just test-register-import`. -Stellar Asset Contracts (`xlm`, `"CODE:ISSUER"`, or a registered name that resolves to a SAC) are not supported by `register_import!` yet and give a compile error. Until they are, register a test asset with `Env::register_stellar_asset_contract_v2`. +### Testing Stellar Asset Contracts + +For an asset name (`xlm`, `native`, `"CODE:ISSUER"`) or a registered name that resolves to a SAC, there is no Wasm to register. `register_import!` puts a test asset at the imported address instead, the same kind `Env::register_stellar_asset_contract_v2` creates, with a generated admin so your test can mint: + +```rs +pub fn tip(env: &Env, from: &Address, amount: i128) { + from.require_auth(); + stellar_registry::import_contract!(env, xlm).transfer(from, env.current_contract_address(), &amount); +} + +#[test] +fn tips() { + let env = Env::default(); + env.mock_all_auths(); + let xlm = register_import!(&env, xlm); + token::StellarAssetClient::new(&env, &xlm).mint(&alice, &1_000); + // ... +} +``` + +It is a stand-in, not the real asset: its code and issuer are the test asset's, whatever name you imported. `args` is not accepted (a SAC has no constructor); `contract = ..` works as for any other name. The [`xlm_tip_jar` fixture](https://github.com/stellar-registry/cli/tree/main/crates/stellar-registry-test/fixtures/contracts/xlm_tip_jar) is a complete example. # Import wasm with `import_contract_client!` diff --git a/justfile b/justfile index f0017b6..cd8f42a 100644 --- a/justfile +++ b/justfile @@ -49,10 +49,11 @@ _test-integration package filter ci="false": test-integration-registry ci="false": just _test-integration stellar-registry-cli 'test(/./)' {{ ci }} -# Test the hello_caller fixture (import_contract! + register_import!) offline, seeding the cache files import_contract! would otherwise fetch +# Test the fixtures that use import_contract! + register_import!: xlm_tip_jar, and hello_caller offline, seeding the cache files import_contract! would otherwise fetch test-register-import: #!/usr/bin/env bash set -euo pipefail + cargo test --package xlm_tip_jar fixture=crates/stellar-registry-test/fixtures/contracts/hello_caller deployed=$fixture/target/stellar/local/deployed stellar contract build --profile contracts --package hello_v1