Browse Source

cli: `anchor account` subcommand to read program account (#1923)

* Setup account subcommand skeleton

* Move IDL deserialization code to syn module

* Change HashMap to serde_json

* Add enum deserialization

* Add account subcommand to docs

* Fix lint

* Add validation for account type

* Fix solana-sdk dependency version

* Fix clippy warnings

* Move IDL deserialization code to cli module

* Remove debug print

* Add integration tests

* Update documentation with example

* Fix clippy warnings

* Fix leftover merge conflict

* run prettier

Co-authored-by: Henry-E <henry.elder@adaptcentre.ie>
Co-authored-by: henrye <henry@notanemail>
Adithya Narayan 2 years ago
parent
commit
b662ff1460

+ 2 - 0
.github/workflows/no-cashing-tests.yaml

@@ -305,6 +305,8 @@ jobs:
             path: tests/relations-derivation
             path: tests/relations-derivation
           - cmd: cd tests/anchor-cli-idl && ./test.sh
           - cmd: cd tests/anchor-cli-idl && ./test.sh
             path: tests/anchor-cli-idl
             path: tests/anchor-cli-idl
+          - cmd: cd tests/anchor-cli-account && anchor test --skip-lint
+            path: tests/anchor-cli-account
     steps:
     steps:
       - uses: actions/checkout@v3
       - uses: actions/checkout@v3
       - uses: ./.github/actions/setup/
       - uses: ./.github/actions/setup/

+ 2 - 0
.github/workflows/tests.yaml

@@ -417,6 +417,8 @@ jobs:
             path: tests/relations-derivation
             path: tests/relations-derivation
           - cmd: cd tests/anchor-cli-idl && ./test.sh
           - cmd: cd tests/anchor-cli-idl && ./test.sh
             path: tests/anchor-cli-idl
             path: tests/anchor-cli-idl
+          - cmd: cd tests/anchor-cli-account && anchor test --skip-lint
+            path: tests/anchor-cli-account
     steps:
     steps:
       - uses: actions/checkout@v3
       - uses: actions/checkout@v3
       - uses: ./.github/actions/setup/
       - uses: ./.github/actions/setup/

+ 1 - 0
CHANGELOG.md

@@ -35,6 +35,7 @@ The minor version will be incremented upon a breaking change and the patch versi
 - cli: Allow custom cluster config ([#2271](https://github.com/coral-xyz/anchor/pull/2271)).
 - cli: Allow custom cluster config ([#2271](https://github.com/coral-xyz/anchor/pull/2271)).
 - ts: Add optional flag to parseLogs to throw an error on decoding failure ([#2043](https://github.com/coral-xyz/anchor/pull/2043)).
 - ts: Add optional flag to parseLogs to throw an error on decoding failure ([#2043](https://github.com/coral-xyz/anchor/pull/2043)).
 - cli: Add `test.validator.geyser_plugin_config` support ([#2016](https://github.com/coral-xyz/anchor/pull/2016)).
 - cli: Add `test.validator.geyser_plugin_config` support ([#2016](https://github.com/coral-xyz/anchor/pull/2016)).
+- cli: Add `account` subcommand to cli ([#1923](https://github.com/project-serum/anchor/pull/1923))
 - cli: Add `ticks_per_slot` option to Validator args ([#1875](https://github.com/coral-xyz/anchor/pull/1875)).
 - cli: Add `ticks_per_slot` option to Validator args ([#1875](https://github.com/coral-xyz/anchor/pull/1875)).
 
 
 ### Fixes
 ### Fixes

+ 247 - 1
cli/src/lib.rs

@@ -5,7 +5,7 @@ use crate::config::{
 use anchor_client::Cluster;
 use anchor_client::Cluster;
 use anchor_lang::idl::{IdlAccount, IdlInstruction, ERASED_AUTHORITY};
 use anchor_lang::idl::{IdlAccount, IdlInstruction, ERASED_AUTHORITY};
 use anchor_lang::{AccountDeserialize, AnchorDeserialize, AnchorSerialize};
 use anchor_lang::{AccountDeserialize, AnchorDeserialize, AnchorSerialize};
-use anchor_syn::idl::Idl;
+use anchor_syn::idl::{EnumFields, Idl, IdlType, IdlTypeDefinitionTy};
 use anyhow::{anyhow, Context, Result};
 use anyhow::{anyhow, Context, Result};
 use clap::Parser;
 use clap::Parser;
 use flate2::read::GzDecoder;
 use flate2::read::GzDecoder;
@@ -17,6 +17,7 @@ use reqwest::blocking::multipart::{Form, Part};
 use reqwest::blocking::Client;
 use reqwest::blocking::Client;
 use semver::{Version, VersionReq};
 use semver::{Version, VersionReq};
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
+use serde_json::{json, Map, Value as JsonValue};
 use solana_client::rpc_client::RpcClient;
 use solana_client::rpc_client::RpcClient;
 use solana_client::rpc_config::RpcSendTransactionConfig;
 use solana_client::rpc_config::RpcSendTransactionConfig;
 use solana_program::instruction::{AccountMeta, Instruction};
 use solana_program::instruction::{AccountMeta, Instruction};
@@ -267,6 +268,16 @@ pub enum Command {
         #[clap(required = false, last = true)]
         #[clap(required = false, last = true)]
         cargo_args: Vec<String>,
         cargo_args: Vec<String>,
     },
     },
+    /// Fetch and deserialize an account using the IDL provided.
+    Account {
+        /// Account struct to deserialize
+        account_type: String,
+        /// Address of the account to deserialize
+        address: Pubkey,
+        /// IDL to use (defaults to workspace IDL)
+        #[clap(long)]
+        idl: Option<String>,
+    },
 }
 }
 
 
 #[derive(Debug, Parser)]
 #[derive(Debug, Parser)]
@@ -471,6 +482,11 @@ pub fn entry(opts: Opts) -> Result<()> {
             skip_lint,
             skip_lint,
             cargo_args,
             cargo_args,
         ),
         ),
+        Command::Account {
+            account_type,
+            address,
+            idl,
+        } => account(&opts.cfg_override, account_type, address, idl),
     }
     }
 }
 }
 
 
@@ -1854,6 +1870,236 @@ fn write_idl(idl: &Idl, out: OutFile) -> Result<()> {
     Ok(())
     Ok(())
 }
 }
 
 
+fn account(
+    cfg_override: &ConfigOverride,
+    account_type: String,
+    address: Pubkey,
+    idl_filepath: Option<String>,
+) -> Result<()> {
+    let (program_name, account_type_name) = account_type
+        .split_once('.') // Split at first occurance of dot
+        .and_then(|(x, y)| y.find('.').map_or_else(|| Some((x, y)), |_| None)) // ensures no dots in second substring
+        .ok_or_else(|| {
+            anyhow!(
+                "Please enter the account struct in the following format: <program_name>.<Account>",
+            )
+        })?;
+
+    let idl = idl_filepath.map_or_else(
+        || {
+            Config::discover(cfg_override)
+                .expect("Error when detecting workspace.")
+                .expect("Not in workspace.")
+                .read_all_programs()
+                .expect("Workspace must contain atleast one program.")
+                .iter()
+                .find(|&p| p.lib_name == *program_name)
+                .unwrap_or_else(|| panic!("Program {} not found in workspace.", program_name))
+                .idl
+                .as_ref()
+                .expect("IDL not found. Please build the program atleast once to generate the IDL.")
+                .clone()
+        },
+        |idl_path| {
+            let bytes = fs::read(idl_path).expect("Unable to read IDL.");
+            let idl: Idl = serde_json::from_reader(&*bytes).expect("Invalid IDL format.");
+
+            if idl.name != program_name {
+                panic!("IDL does not match program {}.", program_name);
+            }
+
+            idl
+        },
+    );
+
+    let mut cluster = &Config::discover(cfg_override)
+        .map(|cfg| cfg.unwrap())
+        .map(|cfg| cfg.provider.cluster.clone())
+        .unwrap_or(Cluster::Localnet);
+    cluster = cfg_override.cluster.as_ref().unwrap_or(cluster);
+
+    let data = RpcClient::new(cluster.url()).get_account_data(&address)?;
+    if data.len() < 8 {
+        return Err(anyhow!(
+            "The account has less than 8 bytes and is not an Anchor account."
+        ));
+    }
+    let mut data_view = &data[8..];
+
+    let deserialized_json =
+        deserialize_idl_struct_to_json(&idl, account_type_name, &mut data_view)?;
+
+    println!(
+        "{}",
+        serde_json::to_string_pretty(&deserialized_json).unwrap()
+    );
+
+    Ok(())
+}
+
+// Deserializes a user defined IDL struct/enum by munching the account data.
+// Recursively deserializes elements one by one
+fn deserialize_idl_struct_to_json(
+    idl: &Idl,
+    account_type_name: &str,
+    data: &mut &[u8],
+) -> Result<JsonValue, anyhow::Error> {
+    let account_type = &idl
+        .accounts
+        .iter()
+        .chain(idl.types.iter())
+        .find(|account_type| account_type.name == account_type_name)
+        .ok_or_else(|| {
+            anyhow::anyhow!("Struct/Enum named {} not found in IDL.", account_type_name)
+        })?
+        .ty;
+
+    let mut deserialized_fields = Map::new();
+
+    match account_type {
+        IdlTypeDefinitionTy::Struct { fields } => {
+            for field in fields {
+                deserialized_fields.insert(
+                    field.name.clone(),
+                    deserialize_idl_type_to_json(&field.ty, data, idl)?,
+                );
+            }
+        }
+        IdlTypeDefinitionTy::Enum { variants } => {
+            let repr = <u8 as AnchorDeserialize>::deserialize(data)?;
+
+            let variant = variants
+                .get(repr as usize)
+                .unwrap_or_else(|| panic!("Error while deserializing enum variant {}", repr));
+
+            let mut value = json!({});
+
+            if let Some(enum_field) = &variant.fields {
+                match enum_field {
+                    EnumFields::Named(fields) => {
+                        let mut values = Map::new();
+
+                        for field in fields {
+                            values.insert(
+                                field.name.clone(),
+                                deserialize_idl_type_to_json(&field.ty, data, idl)?,
+                            );
+                        }
+
+                        value = JsonValue::Object(values);
+                    }
+                    EnumFields::Tuple(fields) => {
+                        let mut values = Vec::new();
+
+                        for field in fields {
+                            values.push(deserialize_idl_type_to_json(field, data, idl)?);
+                        }
+
+                        value = JsonValue::Array(values);
+                    }
+                }
+            }
+
+            deserialized_fields.insert(variant.name.clone(), value);
+        }
+    }
+
+    Ok(JsonValue::Object(deserialized_fields))
+}
+
+// Deserializes a primitive type using AnchorDeserialize
+fn deserialize_idl_type_to_json(
+    idl_type: &IdlType,
+    data: &mut &[u8],
+    parent_idl: &Idl,
+) -> Result<JsonValue, anyhow::Error> {
+    if data.is_empty() {
+        return Err(anyhow::anyhow!("Unable to parse from empty bytes"));
+    }
+
+    Ok(match idl_type {
+        IdlType::Bool => json!(<bool as AnchorDeserialize>::deserialize(data)?),
+        IdlType::U8 => {
+            json!(<u8 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::I8 => {
+            json!(<i8 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::U16 => {
+            json!(<u16 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::I16 => {
+            json!(<i16 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::U32 => {
+            json!(<u32 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::I32 => {
+            json!(<i32 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::F32 => json!(<f32 as AnchorDeserialize>::deserialize(data)?),
+        IdlType::U64 => {
+            json!(<u64 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::I64 => {
+            json!(<i64 as AnchorDeserialize>::deserialize(data)?)
+        }
+        IdlType::F64 => json!(<f64 as AnchorDeserialize>::deserialize(data)?),
+        IdlType::U128 => {
+            // TODO: Remove to_string once serde_json supports u128 deserialization
+            json!(<u128 as AnchorDeserialize>::deserialize(data)?.to_string())
+        }
+        IdlType::I128 => {
+            // TODO: Remove to_string once serde_json supports i128 deserialization
+            json!(<i128 as AnchorDeserialize>::deserialize(data)?.to_string())
+        }
+        IdlType::U256 => todo!("Upon completion of u256 IDL standard"),
+        IdlType::I256 => todo!("Upon completion of i256 IDL standard"),
+        IdlType::Bytes => JsonValue::Array(
+            <Vec<u8> as AnchorDeserialize>::deserialize(data)?
+                .iter()
+                .map(|i| json!(*i))
+                .collect(),
+        ),
+        IdlType::String => json!(<String as AnchorDeserialize>::deserialize(data)?),
+        IdlType::PublicKey => {
+            json!(<Pubkey as AnchorDeserialize>::deserialize(data)?.to_string())
+        }
+        IdlType::Defined(type_name) => deserialize_idl_struct_to_json(parent_idl, type_name, data)?,
+        IdlType::Option(ty) => {
+            let is_present = <u8 as AnchorDeserialize>::deserialize(data)?;
+
+            if is_present == 0 {
+                JsonValue::String("None".to_string())
+            } else {
+                deserialize_idl_type_to_json(ty, data, parent_idl)?
+            }
+        }
+        IdlType::Vec(ty) => {
+            let size: usize = <u32 as AnchorDeserialize>::deserialize(data)?
+                .try_into()
+                .unwrap();
+
+            let mut vec_data: Vec<JsonValue> = Vec::with_capacity(size);
+
+            for _ in 0..size {
+                vec_data.push(deserialize_idl_type_to_json(ty, data, parent_idl)?);
+            }
+
+            JsonValue::Array(vec_data)
+        }
+        IdlType::Array(ty, size) => {
+            let mut array_data: Vec<JsonValue> = Vec::with_capacity(*size);
+
+            for _ in 0..*size {
+                array_data.push(deserialize_idl_type_to_json(ty, data, parent_idl)?);
+            }
+
+            JsonValue::Array(array_data)
+        }
+    })
+}
+
 enum OutFile {
 enum OutFile {
     Stdout,
     Stdout,
     File(PathBuf),
     File(PathBuf),

+ 23 - 0
docs/src/pages/docs/cli.md

@@ -20,6 +20,7 @@ FLAGS:
     -V, --version    Prints version information
     -V, --version    Prints version information
 
 
 SUBCOMMANDS:
 SUBCOMMANDS:
+    account    Fetch and deserialize an account using the IDL provided
     build      Builds the workspace
     build      Builds the workspace
     cluster    Cluster commands
     cluster    Cluster commands
     deploy     Deploys each program in the workspace
     deploy     Deploys each program in the workspace
@@ -37,6 +38,28 @@ SUBCOMMANDS:
                Cargo.toml
                Cargo.toml
 ```
 ```
 
 
+## Account
+
+```
+anchor account <program-name>.<AccountTypeName> <account_pubkey>
+```
+
+Fetches an account with the given public key and deserializes the data to JSON using the type name provided. If this command is run from within a workspace, the workspace's IDL files will be used to get the data types. Otherwise, the path to the IDL file must be provided.
+
+The `program-name` is the name of the program where the account struct resides, usually under `programs/<program-name>`. `program-name` should be provided in a case-sensitive manner exactly as the folder name, usually in kebab-case.
+
+The `AccountTypeName` is the name of the account struct, usually in PascalCase.
+
+The `account_pubkey` refers to the Pubkey of the account to deserialise, in Base58.
+
+Example Usage: `anchor account anchor-escrow.EscrowAccount 3PNkzWKXCsbjijbasnx55NEpJe8DFXvEEbJKdRKpDcfK`, deserializes an account in the given pubkey with the account struct `EscrowAccount` defined in the `anchor-escrow` program.
+
+```
+anchor account <program-name>.<AccountTypeName> <account_pubkey> --idl <path/to/idl.json>
+```
+
+Deserializes the account with the data types provided in the given IDL file even if inside a workspace.
+
 ## Build
 ## Build
 
 
 ```shell
 ```shell

+ 2 - 0
lang/syn/src/idl/mod.rs

@@ -240,6 +240,8 @@ impl std::str::FromStr for IdlType {
             "f64" => IdlType::F64,
             "f64" => IdlType::F64,
             "u128" => IdlType::U128,
             "u128" => IdlType::U128,
             "i128" => IdlType::I128,
             "i128" => IdlType::I128,
+            "u256" => IdlType::U256,
+            "i256" => IdlType::I256,
             "Vec<u8>" => IdlType::Bytes,
             "Vec<u8>" => IdlType::Bytes,
             "String" | "&str" | "&'staticstr" => IdlType::String,
             "String" | "&str" | "&'staticstr" => IdlType::String,
             "Pubkey" => IdlType::PublicKey,
             "Pubkey" => IdlType::PublicKey,

+ 14 - 0
tests/anchor-cli-account/Anchor.toml

@@ -0,0 +1,14 @@
+[programs.localnet]
+account_command = "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"
+
+[registry]
+url = "https://anchor.projectserum.com"
+
+[provider]
+cluster = "localnet"
+wallet = "~/.config/solana/id.json"
+
+[scripts]
+test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
+
+[features]

+ 7 - 0
tests/anchor-cli-account/Cargo.toml

@@ -0,0 +1,7 @@
+[profile.release]
+overflow-checks = true
+
+[workspace]
+members = [
+    "programs/*"
+]

+ 19 - 0
tests/anchor-cli-account/package.json

@@ -0,0 +1,19 @@
+{
+  "name": "anchor-cli-account",
+  "version": "0.25.0",
+  "license": "(MIT OR Apache-2.0)",
+  "homepage": "https://github.com/coral-xyz/anchor#readme",
+  "bugs": {
+    "url": "https://github.com/coral-xyz/anchor/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/coral-xyz/anchor.git"
+  },
+  "engines": {
+    "node": ">=11"
+  },
+  "scripts": {
+    "test": "anchor test"
+  }
+}

+ 19 - 0
tests/anchor-cli-account/programs/account-command/Cargo.toml

@@ -0,0 +1,19 @@
+[package]
+name = "account-command"
+version = "0.1.0"
+description = "Created with Anchor"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "lib"]
+name = "account_command"
+
+[features]
+no-entrypoint = []
+no-idl = []
+no-log-ix-name = []
+cpi = ["no-entrypoint"]
+default = []
+
+[dependencies]
+anchor-lang = "0.25.0"

+ 2 - 0
tests/anchor-cli-account/programs/account-command/Xargo.toml

@@ -0,0 +1,2 @@
+[target.bpfel-unknown-unknown.dependencies.std]
+features = []

+ 55 - 0
tests/anchor-cli-account/programs/account-command/src/lib.rs

@@ -0,0 +1,55 @@
+use anchor_lang::prelude::*;
+
+declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
+
+#[program]
+pub mod account_command {
+    use super::*;
+
+    pub fn initialize(
+        ctx: Context<Initialize>,
+        balance: f32,
+        amount: u32,
+        memo: String,
+        values: Vec<u128>,
+    ) -> Result<()> {
+        let my_account = &mut ctx.accounts.my_account;
+
+        my_account.balance = balance;
+        my_account.delegate_pubkey = ctx.accounts.user.key().clone();
+        my_account.sub = Sub {
+            values,
+            state: State::Confirmed { amount, memo },
+        };
+
+        Ok(())
+    }
+}
+
+#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
+pub enum State {
+    Pending,
+    Confirmed { amount: u32, memo: String },
+}
+
+#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
+pub struct Sub {
+    pub values: Vec<u128>,
+    pub state: State,
+}
+
+#[account]
+pub struct MyAccount {
+    pub balance: f32,
+    pub delegate_pubkey: Pubkey,
+    pub sub: Sub,
+}
+
+#[derive(Accounts)]
+pub struct Initialize<'info> {
+    #[account(init, payer = user, space = 8 + 1000)]
+    pub my_account: Account<'info, MyAccount>,
+    #[account(mut)]
+    pub user: Signer<'info>,
+    pub system_program: Program<'info, System>,
+}

+ 78 - 0
tests/anchor-cli-account/tests/account.ts

@@ -0,0 +1,78 @@
+import * as anchor from "@project-serum/anchor";
+import { Program } from "@project-serum/anchor";
+import { AccountCommand } from "../target/types/account_command";
+import { assert } from "chai";
+import { execSync } from "child_process";
+import { sleep } from "@project-serum/common";
+
+describe("Test CLI account commands", () => {
+  // Configure the client to use the local cluster.
+  const provider = anchor.AnchorProvider.env();
+
+  anchor.setProvider(provider);
+
+  const program = anchor.workspace.AccountCommand as Program<AccountCommand>;
+
+  it("Can fetch and deserialize account using the account command", async () => {
+    const myAccount = anchor.web3.Keypair.generate();
+
+    const balance = -2.5;
+    const amount = 108;
+    const memo = "account test";
+    const values = [1, 2, 3, 1000];
+
+    await program.methods
+      .initialize(
+        balance,
+        new anchor.BN(amount),
+        memo,
+        values.map((x) => new anchor.BN(x))
+      )
+      .accounts({
+        myAccount: myAccount.publicKey,
+        user: provider.wallet.publicKey,
+        systemProgram: anchor.web3.SystemProgram.programId,
+      })
+      .signers([myAccount])
+      .rpc();
+
+    let output: any = {};
+    for (let tries = 0; tries < 20; tries++) {
+      try {
+        output = JSON.parse(
+          execSync(
+            `anchor account account_command.MyAccount ${myAccount.publicKey}`,
+            { stdio: "pipe" }
+          ).toString()
+        );
+        break;
+      } catch (e) {
+        if (!e.stderr.toString().startsWith("Error: AccountNotFound")) {
+          throw e;
+        }
+      }
+
+      await sleep(5000);
+    }
+
+    assert(output.balance == balance, "Balance deserialized incorrectly");
+    assert(
+      output.delegatePubkey == provider.wallet.publicKey,
+      "delegatePubkey deserialized incorrectly"
+    );
+    assert(
+      output.sub.state.Confirmed.amount === amount,
+      "Amount deserialized incorrectly"
+    );
+    assert(
+      output.sub.state.Confirmed.memo === memo,
+      "Memo deserialized incorrectly"
+    );
+    for (let i = 0; i < values.length; i++) {
+      assert(
+        output.sub.values[i] == values[i],
+        "Values deserialized incorrectly"
+      );
+    }
+  });
+});

+ 10 - 0
tests/anchor-cli-account/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "types": ["mocha", "chai"],
+    "typeRoots": ["./node_modules/@types"],
+    "lib": ["es2015"],
+    "module": "commonjs",
+    "target": "es6",
+    "esModuleInterop": true
+  }
+}

+ 1 - 0
tests/package.json

@@ -6,6 +6,7 @@
     "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
     "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
   },
   },
   "workspaces": [
   "workspaces": [
+    "anchor-cli-account",
     "anchor-cli-idl",
     "anchor-cli-idl",
     "cashiers-check",
     "cashiers-check",
     "cfo",
     "cfo",