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}