diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs
index fdbf3d550..d7f024e4c 100644
--- a/cli/src/commands/mod.rs
+++ b/cli/src/commands/mod.rs
@@ -9,6 +9,7 @@ pub mod idl;
pub mod init;
pub mod know;
pub mod mcp;
+pub mod program_decode;
pub mod programs;
pub mod protocol;
pub mod protocol_bin;
diff --git a/cli/src/commands/program_decode.rs b/cli/src/commands/program_decode.rs
new file mode 100644
index 000000000..696a4004a
--- /dev/null
+++ b/cli/src/commands/program_decode.rs
@@ -0,0 +1,946 @@
+//! `a4 program account decode
`: one account, decoded by Program
+//! Read for the program's installed release.
+//!
+//! - **Production** (the default): the account is read through the managed
+//! Solana gateway and decoded by hosted Program Read.
+//! - **Dev** (`--dev`): the account is read from the project's cloud dev
+//! environment and decoded by the environment's Program Read, so it shows
+//! the fork's values. The read session is minted for the environment with
+//! the account's key, as `a4 dev` does.
+//!
+//! A program the project depends on decodes at the release arete.lock pins.
+//! Without `--program`, the program is the account's owner, looked up in the
+//! project and then the registry. Without `--account`, the account type is the IDL account whose
+//! discriminator starts the account's data. An environment that serves another
+//! release of the program answers for its own account types: `--account` goes
+//! to it unchecked, for a type the installed IDL doesn't name.
+
+use std::path::Path;
+
+use anyhow::{anyhow, bail, Context, Result};
+use base64::Engine as _;
+use clap::Args;
+use serde_json::{json, Value};
+
+use crate::api_client::{
+ ApiClient, RegistryCapabilityInstallBinding, RegistryProgramInstallResponse,
+ RegistryProgramInstallTransport,
+};
+use crate::commands::dev::env::EnvArgs;
+use crate::project::installer::resolve_registry_batch;
+use crate::project::lockfile::LockedDependency;
+use crate::project::manifest::{DependencyKind, DependencySourceV1, InstallTarget};
+use crate::project::resolver::{RegistryDependencyRequest, ResolvedRegistryDependency};
+use crate::project::{ProjectLock, ProjectManifest};
+
+#[derive(Args, Debug)]
+pub struct DecodeArgs {
+ /// The account's address
+ pub address: String,
+
+ /// The program: an arete.toml alias or a registry package
+ /// [default: the account's owner, when the registry has it]
+ #[arg(long, value_name = "ALIAS|PACKAGE")]
+ pub program: Option,
+
+ /// The account type, by its IDL name [default: the IDL account whose
+ /// discriminator the data starts with]
+ #[arg(long, value_name = "TYPE")]
+ pub account: Option,
+
+ /// Read the account from the project's cloud dev environment and decode
+ /// it there, so it shows the fork's values
+ #[arg(long)]
+ pub dev: bool,
+
+ #[command(flatten)]
+ pub env: EnvArgs,
+}
+
+/// Where the read goes. `--dev` selects the environment today; when the
+/// SDKs' environment switch lands, this is the one place that reads it, so
+/// the command follows the switch like the SDKs do.
+enum ReadTarget<'a> {
+ Production,
+ Dev(&'a EnvArgs),
+}
+
+fn read_target(args: &DecodeArgs) -> Result> {
+ if args.dev {
+ return Ok(ReadTarget::Dev(&args.env));
+ }
+ if args.env.env.is_some() || args.env.project.project.is_some() {
+ bail!("--env and --project choose a dev environment; add --dev");
+ }
+ Ok(ReadTarget::Production)
+}
+
+pub fn run(config_path: &str, args: DecodeArgs, json_output: bool) -> Result<()> {
+ if !is_pubkey(&args.address) {
+ bail!("'{}' is not a Solana address", args.address);
+ }
+ let client = ApiClient::new()?;
+ let decoded = match read_target(&args)? {
+ ReadTarget::Production => decode_production(&client, config_path, &args)?,
+ ReadTarget::Dev(env) => decode_dev(&client, config_path, &args, env)?,
+ };
+ print_decoded(&decoded, json_output)
+}
+
+/// What a decode read and where.
+#[derive(Debug, Clone, PartialEq)]
+struct Decoded {
+ address: String,
+ program: String,
+ program_id: String,
+ account: String,
+ release: String,
+ /// `production`, or the dev environment's id.
+ environment: String,
+ value: Value,
+}
+
+fn print_decoded(decoded: &Decoded, json_output: bool) -> Result<()> {
+ if json_output {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&json!({
+ "address": decoded.address,
+ "program": decoded.program,
+ "programId": decoded.program_id,
+ "account": decoded.account,
+ "programReleaseHash": decoded.release,
+ "environment": decoded.environment,
+ "value": decoded.value,
+ }))?
+ );
+ return Ok(());
+ }
+ println!(
+ "{} {} ({} of {}, {})",
+ decoded.address, decoded.account, decoded.account, decoded.program, decoded.environment
+ );
+ println!("{}", serde_json::to_string_pretty(&decoded.value)?);
+ Ok(())
+}
+
+/// One account's raw state: its owner and data.
+#[derive(Debug, Clone, PartialEq)]
+struct RawAccount {
+ owner: String,
+ data: Vec,
+}
+
+fn decode_production(client: &ApiClient, config_path: &str, args: &DecodeArgs) -> Result {
+ let named = args
+ .program
+ .as_deref()
+ .map(|program| install_for(client, config_path, program))
+ .transpose()?;
+ // Any program's chain binding names the managed Solana gateway, which
+ // reads any account; without --program, the registry's first program
+ // provides one.
+ let gateway_install = match &named {
+ Some(install) => install.clone(),
+ None => {
+ let first = client
+ .list_registry_programs()?
+ .into_iter()
+ .next()
+ .ok_or_else(|| anyhow!("The registry lists no programs to read through"))?;
+ client.get_registry_program_install(&first.install_name, None)?
+ }
+ };
+ let chain = gateway_install.chain_binding.as_ref().ok_or_else(|| {
+ anyhow!(
+ "{} has no hosted chain read binding to read {} through",
+ gateway_install.install_name,
+ args.address
+ )
+ })?;
+ let raw = read_chain_account(client, chain, &args.address)?
+ .ok_or_else(|| anyhow!("No account exists at {}", args.address))?;
+ let install = match named {
+ Some(install) => install,
+ None => owner_install(client, config_path, &raw.owner, &args.address)?,
+ };
+ let (binding_id, endpoint, session_endpoint) = program_read_binding(&install)?;
+ let account = account_type(&install, &raw, args.account.as_deref())?;
+ check_owner(&install, &raw, &args.address)?;
+ let release = install.release.program_release_hash.clone();
+ let token = client.mint_target_session(
+ &session_endpoint,
+ &json!({
+ "scopes": ["read"],
+ "targetKind": "program-read-binding",
+ "targetId": binding_id,
+ "programReleaseHash": release,
+ }),
+ )?;
+ let value = read_decoded(&endpoint, &release, &account, &args.address, &token)?;
+ Ok(Decoded {
+ address: args.address.clone(),
+ program: install.install_name.clone(),
+ program_id: install.definition.program_id.clone(),
+ account,
+ release,
+ environment: "production".to_string(),
+ value,
+ })
+}
+
+/// The environment calls are async; the registry, session and Program Read
+/// calls are blocking. Each async step runs to completion on one runtime
+/// before any blocking call, so no blocking client runs inside it.
+fn decode_dev(
+ client: &ApiClient,
+ config_path: &str,
+ args: &DecodeArgs,
+ env_args: &EnvArgs,
+) -> Result {
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .context("Failed to start the async runtime")?;
+ let (rpc, info) = runtime.block_on(async {
+ let rpc = env_args.rpc().await?;
+ let info = rpc
+ .call(
+ "getAccountInfo",
+ json!([args.address, { "encoding": "base64", "commitment": "processed" }]),
+ )
+ .await
+ .map_err(crate::commands::dev::env::explain)?;
+ anyhow::Ok((rpc, info))
+ })?;
+ let env = rpc.env().to_string();
+ let raw = raw_from_rpc(&info)?
+ .ok_or_else(|| anyhow!("No account exists at {} in {env}", args.address))?;
+ // `--project` names the project, so its arete.toml names the aliases.
+ let project_config = env_args
+ .project
+ .project
+ .as_ref()
+ .map(|dir| dir.join("arete.toml").display().to_string());
+ let config_path = project_config.as_deref().unwrap_or(config_path);
+ let install = match args.program.as_deref() {
+ Some(program) => install_for(client, config_path, program)?,
+ None => owner_install(client, config_path, &raw.owner, &args.address)?,
+ };
+ check_owner(&install, &raw, &args.address)?;
+ let (binding_id, _, session_endpoint) = program_read_binding(&install)?;
+ let installed_release = install.release.program_release_hash.clone();
+ let resolved = runtime
+ .block_on(rpc.api().resolve(
+ &env,
+ json!({
+ "programReads": [{
+ "programId": install.definition.program_id,
+ "programReadBindingId": binding_id,
+ "programReleaseHash": installed_release,
+ }],
+ }),
+ ))
+ .map_err(crate::commands::dev::env::explain)?;
+ drop(rpc);
+ drop(runtime);
+ let (endpoint, release) = dev_program_read_target(&resolved)?;
+ let account = if release == installed_release {
+ account_type(&install, &raw, args.account.as_deref())?
+ } else {
+ served_account_type(&install, &raw, args.account.as_deref())?
+ };
+ let token = client.mint_target_session(
+ &session_endpoint,
+ &json!({
+ "scopes": ["read"],
+ "targetKind": "program-read-binding",
+ "targetId": binding_id,
+ "programReleaseHash": installed_release,
+ "environment": env,
+ }),
+ )?;
+ let value = read_decoded(&endpoint, &release, &account, &args.address, &token)?;
+ Ok(Decoded {
+ address: args.address.clone(),
+ program: install.install_name.clone(),
+ program_id: install.definition.program_id.clone(),
+ account,
+ release,
+ environment: env,
+ value,
+ })
+}
+
+/// The dev Program Read endpoint and release from a resolve answer, or the
+/// refusal with its fix.
+fn dev_program_read_target(resolved: &Value) -> Result<(String, String)> {
+ let answer = &resolved["programReads"][0];
+ match answer["status"].as_str() {
+ Some("target") => {
+ let endpoint = answer["endpoint"]
+ .as_str()
+ .filter(|endpoint| !endpoint.is_empty())
+ .ok_or_else(|| anyhow!("The environment's Program Read answer has no endpoint"))?;
+ let release = answer["programReleaseHash"]
+ .as_str()
+ .ok_or_else(|| anyhow!("The environment's Program Read answer has no release"))?;
+ Ok((endpoint.to_string(), release.to_string()))
+ }
+ Some("refused") => bail!(
+ "{} ({}). {}",
+ answer["message"]
+ .as_str()
+ .unwrap_or("The environment can't read this program"),
+ answer["code"].as_str().unwrap_or("refused"),
+ answer["fix"].as_str().unwrap_or_default()
+ ),
+ _ => bail!("The environment returned no Program Read answer"),
+ }
+}
+
+/// The install for `program`: an arete.toml program alias at the project's
+/// pinned release, else `program` as a registry reference.
+fn install_for(
+ client: &ApiClient,
+ config_path: &str,
+ program: &str,
+) -> Result {
+ let pinned = project_install(Path::new(config_path), |alias, _| alias == program)
+ .with_context(|| format!("Could not resolve program '{program}'"))?;
+ match pinned {
+ Some(install) => Ok(install),
+ None => client
+ .get_registry_program_install(program, None)
+ .with_context(|| format!("Could not resolve program '{program}'")),
+ }
+}
+
+/// The project's registry program dependency `selected` picks, at the release
+/// `a4 install` pins it to. No readable arete.toml, or no such dependency, is
+/// no answer: the command also runs outside projects.
+fn project_install(
+ config_path: &Path,
+ selected: impl Fn(&str, Option<&LockedDependency>) -> bool,
+) -> Result