Skip to main content

canton_core/
localnet.rs

1//! Reading a local development network out of the environment.
2//!
3//! [canton-devkit] runs a Splice LocalNet — two participants and a
4//! super-validator — and exports everything an application needs to talk to it:
5//!
6//! ```text
7//! eval "$(canton-devkit localnet env demo)"   # or: dpm localnet env demo
8//! ```
9//!
10//! That sets a documented set of `CANTON_*` variables, and this module is the
11//! one place in the SDK that knows their names. [`Config::from_env`] turns them
12//! into a working gRPC configuration, so an application that would otherwise
13//! carry endpoint and token plumbing for local development carries none:
14//!
15//! ```no_run
16//! # async fn run() -> canton_core::Result<()> {
17//! let config = canton_core::Config::from_env()?;
18//! # let _ = config;
19//! # Ok(()) }
20//! ```
21//!
22//! # The variables
23//!
24//! | Variable | Meaning |
25//! |---|---|
26//! | `CANTON_GRPC_LEDGER_API_URL` | gRPC Ledger API, **scheme-less** `host:port` |
27//! | `CANTON_JSON_LEDGER_API_URL` | JSON Ledger API, with an `http(s)://` scheme |
28//! | `CANTON_<ROLE>_JWT` | that role's bearer token |
29//! | `CANTON_<ALIAS>_PARTY` | an on-ledger party id |
30//! | `CANTON_INSTANCE`, `CANTON_SPLICE_VERSION` | which network this is |
31//!
32//! The unqualified URL variables point at the **app-provider** participant, the
33//! usual target for an application. The other participants are reached by role:
34//! `CANTON_APP_USER_GRPC_LEDGER_API_URL`, `CANTON_SV_JWT`, and so on.
35//! [`Config::from_env_for`] takes the role name and applies the same
36//! normalisation the exporter does (upper-case, `-` becomes `_`), so
37//! `"app-user"` and `"app_user"` both work.
38//!
39//! # Two things worth knowing
40//!
41//! The URLs are **nginx virtual-host names** —
42//! `grpc-ledger-api.app-provider.demo.localhost` — not plain hosts. The name is
43//! what routes the request, so it has to survive into the `:authority` (gRPC) or
44//! `Host` (HTTP) header rather than being resolved away by the caller. Passing
45//! the URL through unchanged, as everything here does, is what keeps that true.
46//! `*.localhost` resolves to loopback on macOS and on Linux with
47//! systemd-resolved; where it does not, an `/etc/hosts` entry is the fix —
48//! substituting `127.0.0.1` is not, because the vhost is then lost.
49//!
50//! The gRPC URL has **no scheme**, because that is what a gRPC client dials.
51//! [`Config`] accepts it that way.
52//!
53//! # Anything else that sets the same names
54//!
55//! Nothing here is devkit-specific beyond the variable names, and two generic
56//! overrides come first for environments that are not a LocalNet at all:
57//! `CANTON_ENDPOINT` and `CANTON_TOKEN` win over everything below them.
58//!
59//! [canton-devkit]: https://github.com/bitdynamics-ab/canton-devkit
60
61use crate::{Config, Error, Result};
62
63/// `CANTON_ENDPOINT` — an explicit gRPC endpoint, overriding the LocalNet one.
64const ENDPOINT_OVERRIDE: &str = "CANTON_ENDPOINT";
65/// `CANTON_TOKEN` — an explicit bearer token, overriding the role's JWT.
66const TOKEN_OVERRIDE: &str = "CANTON_TOKEN";
67/// The role the unqualified URL variables point at.
68const DEFAULT_ROLE: &str = "app-provider";
69
70/// Where the variables come from.
71///
72/// The process environment in normal use, a map in the tests. Reading through
73/// this rather than calling `std::env::var` directly is what lets the tests
74/// cover the precedence rules at all: setting a variable is `unsafe` as of the
75/// 2024 edition and this workspace forbids `unsafe`, and tests that mutate the
76/// process environment would have to run under a lock to avoid seeing each
77/// other's values.
78struct Source<F: Fn(&str) -> Option<String>>(F);
79
80impl<F: Fn(&str) -> Option<String>> Source<F> {
81    /// Read `name`, treating a set-but-empty value as absent.
82    ///
83    /// `std::env::var` reports `FOO=` as `Ok("")`. That is how an exporter
84    /// spells "not known yet", and taking it as a value produces a client that
85    /// authenticates with an empty token and then fails every call with a
86    /// permission error naming nothing.
87    fn get(&self, name: &str) -> Option<String> {
88        (self.0)(name).filter(|value| !value.trim().is_empty())
89    }
90
91    fn grpc_endpoint(&self, role: Option<&str>) -> Option<String> {
92        self.get(ENDPOINT_OVERRIDE)
93            .or_else(|| self.get(&role_variable(role, "GRPC_LEDGER_API_URL")))
94    }
95
96    fn json_endpoint(&self, role: Option<&str>) -> Option<String> {
97        self.get(&role_variable(role, "JSON_LEDGER_API_URL"))
98    }
99
100    fn token(&self, role: Option<&str>) -> Option<String> {
101        self.get(TOKEN_OVERRIDE)
102            .or_else(|| self.get(&format!("{}_JWT", prefix(role.unwrap_or(DEFAULT_ROLE)))))
103    }
104
105    fn config(&self, role: Option<&str>) -> Result<Config> {
106        let endpoint = self
107            .grpc_endpoint(role)
108            .ok_or_else(|| missing_endpoint(role))?;
109        let config = Config::new(endpoint);
110        Ok(match self.token(role) {
111            Some(token) => config.with_token(token),
112            // A LocalNet started without authentication exports no JWT. That is
113            // a network, not a misconfiguration.
114            None => config,
115        })
116    }
117}
118
119/// The process environment.
120fn process() -> Source<impl Fn(&str) -> Option<String>> {
121    Source(|name: &str| std::env::var(name).ok())
122}
123
124/// `CANTON_` + a role or alias, upper-cased with `-` turned into `_`.
125///
126/// Mirrors the exporter's own normalisation, so `"app-user"`, `"app_user"` and
127/// `"APP_USER"` all name the same variables.
128fn prefix(role: &str) -> String {
129    format!("CANTON_{}", role.to_uppercase().replace('-', "_"))
130}
131
132/// `CANTON_<ROLE>_<SUFFIX>`, or the unqualified `CANTON_<SUFFIX>` when no role
133/// is named — which is how the exporter spells "the default participant".
134fn role_variable(role: Option<&str>, suffix: &str) -> String {
135    match role {
136        None => format!("CANTON_{suffix}"),
137        Some(role) => format!("{}_{suffix}", prefix(role)),
138    }
139}
140
141/// The error for "there is no network in this environment", written so the
142/// reader knows which command produces one.
143fn missing_endpoint(role: Option<&str>) -> Error {
144    let variable = role_variable(role, "GRPC_LEDGER_API_URL");
145    Error::InvalidRequest(format!(
146        "no ledger endpoint in the environment: set {variable} (or {ENDPOINT_OVERRIDE}). \
147         A local network exports it with `canton-devkit localnet env <instance>`; \
148         run that through `eval` first."
149    ))
150}
151
152/// The gRPC Ledger API URL for `role`, or the default participant's when `role`
153/// is `None`. `CANTON_ENDPOINT` overrides both.
154#[must_use]
155pub fn grpc_endpoint(role: Option<&str>) -> Option<String> {
156    process().grpc_endpoint(role)
157}
158
159/// The JSON Ledger API base URL for `role`, or the default participant's when
160/// `role` is `None`.
161#[must_use]
162pub fn json_endpoint(role: Option<&str>) -> Option<String> {
163    process().json_endpoint(role)
164}
165
166/// The bearer token for `role`, or the default participant's when `role` is
167/// `None`. `CANTON_TOKEN` overrides both.
168///
169/// These are development credentials issued by the LocalNet's own issuer, with
170/// a long life and no revocation. They are not something to carry into a
171/// deployment.
172#[must_use]
173pub fn token(role: Option<&str>) -> Option<String> {
174    process().token(role)
175}
176
177/// The on-ledger party id recorded under `alias` — a role (`"app-provider"`) or
178/// a name given to a party created later (`"bob"`).
179///
180/// The party **id**, not the Ledger API user name: this is what goes in
181/// `act_as`.
182#[must_use]
183pub fn party(alias: &str) -> Option<String> {
184    process().get(&format!("{}_PARTY", prefix(alias)))
185}
186
187/// The instance name the environment was exported from (`CANTON_INSTANCE`).
188#[must_use]
189pub fn instance() -> Option<String> {
190    process().get("CANTON_INSTANCE")
191}
192
193/// The Splice release the network is running (`CANTON_SPLICE_VERSION`).
194#[must_use]
195pub fn splice_version() -> Option<String> {
196    process().get("CANTON_SPLICE_VERSION")
197}
198
199impl Config {
200    /// Build a configuration from a LocalNet exported into the environment —
201    /// the **app-provider** participant, with its token.
202    ///
203    /// See the [module documentation](self) for the variables read and the two
204    /// things worth knowing about the URLs.
205    ///
206    /// # Errors
207    /// Returns [`Error::InvalidRequest`] when no endpoint variable is set,
208    /// naming the variable and the command that exports it. A missing token is
209    /// **not** an error: an unauthenticated LocalNet is a normal thing to point
210    /// this at.
211    pub fn from_env() -> Result<Self> {
212        process().config(None)
213    }
214
215    /// The same, for a participant other than the default: `"app-user"`,
216    /// `"sv"`, or any role the exporter knows.
217    ///
218    /// # Errors
219    /// As [`Config::from_env`], naming that role's variable.
220    pub fn from_env_for(role: &str) -> Result<Self> {
221        process().config(Some(role))
222    }
223}
224
225#[cfg(test)]
226#[allow(clippy::unwrap_used)]
227mod tests {
228    use super::*;
229    use crate::Auth;
230    use std::collections::HashMap;
231
232    /// The exact strings `canton-devkit localnet env` prints — vhost names, and
233    /// a gRPC URL with no scheme. Copied from the exporter rather than
234    /// paraphrased: paraphrasing is how the scheme gets quietly added and the
235    /// thing this has to cope with quietly disappears.
236    fn devkit() -> Source<impl Fn(&str) -> Option<String>> {
237        let vars: HashMap<&str, &str> = [
238            ("CANTON_INSTANCE", "demo"),
239            ("CANTON_SPLICE_VERSION", "0.6.12"),
240            (
241                "CANTON_GRPC_LEDGER_API_URL",
242                "grpc-ledger-api.app-provider.demo.localhost:3901",
243            ),
244            (
245                "CANTON_JSON_LEDGER_API_URL",
246                "http://json-ledger-api.app-provider.demo.localhost:3901",
247            ),
248            (
249                "CANTON_APP_USER_GRPC_LEDGER_API_URL",
250                "grpc-ledger-api.app-user.demo.localhost:2901",
251            ),
252            ("CANTON_APP_PROVIDER_JWT", "provider.jwt.token"),
253            ("CANTON_APP_USER_JWT", "user.jwt.token"),
254            ("CANTON_APP_PROVIDER_PARTY", "app_provider::1220abcd"),
255            ("CANTON_BOB_PARTY", "bob::1220abcd"),
256            // Left over from an earlier shell, which is the realistic state.
257            ("CANTON_ENDPOINT", ""),
258            ("CANTON_TOKEN", ""),
259        ]
260        .into_iter()
261        .collect();
262        Source(move |name: &str| vars.get(name).map(|value| (*value).to_string()))
263    }
264
265    #[test]
266    fn a_devkit_environment_becomes_a_working_configuration() {
267        let config = devkit().config(None).unwrap();
268
269        // The vhost name has to survive. It is what routes the request through
270        // nginx, so rewriting it to 127.0.0.1 would reach the port and be
271        // refused by the virtual host.
272        assert_eq!(
273            config.endpoint(),
274            "grpc-ledger-api.app-provider.demo.localhost:3901"
275        );
276        assert!(matches!(config.auth(), Auth::Static(t) if t == "provider.jwt.token"));
277
278        // The scheme-less form is stored as exported. Supplying the scheme is
279        // the channel builder's job, covered where that lives
280        // (`config::tests::a_scheme_less_host_and_port_gets_the_scheme_it_implies`);
281        // adding it here would mean two places decide what the endpoint is.
282        assert!(!config.endpoint().contains("://"));
283    }
284
285    #[test]
286    fn a_role_selects_that_participant_and_its_token() {
287        for spelling in ["app-user", "app_user", "APP-USER"] {
288            let config = devkit().config(Some(spelling)).unwrap();
289            assert_eq!(
290                config.endpoint(),
291                "grpc-ledger-api.app-user.demo.localhost:2901",
292                "spelling {spelling}"
293            );
294            assert!(matches!(config.auth(), Auth::Static(t) if t == "user.jwt.token"));
295        }
296    }
297
298    #[test]
299    fn the_json_lane_reads_the_same_environment() {
300        assert_eq!(
301            devkit().json_endpoint(None).as_deref(),
302            Some("http://json-ledger-api.app-provider.demo.localhost:3901")
303        );
304        // A role with no JSON URL exported yields nothing rather than the
305        // default participant's — silently talking to the wrong node is worse
306        // than not connecting.
307        assert_eq!(devkit().json_endpoint(Some("sv")), None);
308    }
309
310    /// A set-but-empty variable is the trap. `std::env::var` reports it as a
311    /// value, so an empty `CANTON_ENDPOINT` left behind by a previous shell
312    /// would win over the real LocalNet URL and point the client at nothing.
313    #[test]
314    fn an_empty_variable_does_not_shadow_a_real_one() {
315        let source = devkit(); // CANTON_ENDPOINT and CANTON_TOKEN are ""
316        let config = source.config(None).unwrap();
317
318        assert_eq!(
319            config.endpoint(),
320            "grpc-ledger-api.app-provider.demo.localhost:3901"
321        );
322        assert!(matches!(config.auth(), Auth::Static(t) if t == "provider.jwt.token"));
323    }
324
325    #[test]
326    fn an_explicit_endpoint_wins_over_the_local_network() {
327        let source = Source(|name: &str| {
328            Some(
329                match name {
330                    ENDPOINT_OVERRIDE => "https://ledger.example:443",
331                    TOKEN_OVERRIDE => "deployment.token",
332                    "CANTON_GRPC_LEDGER_API_URL" => "grpc-ledger-api.demo.localhost:3901",
333                    _ => return None,
334                }
335                .to_string(),
336            )
337        });
338
339        let config = source.config(None).unwrap();
340        assert_eq!(config.endpoint(), "https://ledger.example:443");
341        assert!(matches!(config.auth(), Auth::Static(t) if t == "deployment.token"));
342    }
343
344    #[test]
345    fn an_empty_environment_says_which_command_produces_one() {
346        let empty = Source(|_: &str| None);
347
348        let error = empty.config(None).unwrap_err().to_string();
349        assert!(error.contains("CANTON_GRPC_LEDGER_API_URL"), "{error}");
350        assert!(error.contains("canton-devkit localnet env"), "{error}");
351
352        // The role form names that role's variable, not the default one — the
353        // reader is about to go and set it.
354        let error = empty.config(Some("sv")).unwrap_err().to_string();
355        assert!(error.contains("CANTON_SV_GRPC_LEDGER_API_URL"), "{error}");
356    }
357
358    /// A LocalNet started without authentication exports no JWT. Refusing it
359    /// would refuse the simplest network there is.
360    #[test]
361    fn a_network_without_tokens_is_still_a_network() {
362        let source = Source(|name: &str| {
363            (name == "CANTON_GRPC_LEDGER_API_URL").then(|| "localhost:3901".to_string())
364        });
365
366        let config = source.config(None).unwrap();
367        assert!(matches!(config.auth(), Auth::None));
368    }
369
370    #[test]
371    fn party_ids_are_reachable_by_role_and_by_alias() {
372        let source = devkit();
373        let party = |alias: &str| source.get(&format!("{}_PARTY", prefix(alias)));
374
375        assert_eq!(
376            party("app-provider").as_deref(),
377            Some("app_provider::1220abcd")
378        );
379        // A party created later gets the same treatment as a built-in role.
380        assert_eq!(party("bob").as_deref(), Some("bob::1220abcd"));
381        assert_eq!(party("nobody"), None);
382    }
383}