Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 107 additions & 31 deletions crates/stellar-registry-macro/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<proc_macro2::TokenStream> {
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()
Expand Down Expand Up @@ -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}");
}
}
23 changes: 18 additions & 5 deletions crates/stellar-registry-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 = <expr>` works as for any other name.
#[proc_macro]
pub fn register_import(input: TokenStream) -> TokenStream {
contract::register_import(input).to_token_stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,3 +21,4 @@ stellar-registry = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
stellar-registry = { workspace = true, features = ["testutils"] }
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -58,37 +17,41 @@ 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())
}
}

#[cfg(test)]
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
);
}
Expand Down
22 changes: 21 additions & 1 deletion crates/stellar-registry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!`

Expand Down
3 changes: 2 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading