Prechádzať zdrojové kódy

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 rokov pred
rodič
commit
b662ff1460

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

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

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

@@ -417,6 +417,8 @@ jobs:
             path: tests/relations-derivation
           - cmd: cd tests/anchor-cli-idl && ./test.sh
             path: tests/anchor-cli-idl
+          - cmd: cd tests/anchor-cli-account && anchor test --skip-lint
+            path: tests/anchor-cli-account
     steps:
       - uses: actions/checkout@v3
       - 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)).
 - 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 `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)).
 
 ### Fixes

+ 247 - 1
cli/src/lib.rs

@@ -5,7 +5,7 @@ use crate::config::{
 use anchor_client::Cluster;
 use anchor_lang::idl::{IdlAccount, IdlInstruction, ERASED_AUTHORITY};
 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 clap::Parser;
 use flate2::read::GzDecoder;
@@ -17,6 +17,7 @@ use reqwest::blocking::multipart::{Form, Part};
 use reqwest::blocking::Client;
 use semver::{Version, VersionReq};
 use serde::{Deserialize, Serialize};
+use serde_json::{json, Map, Value as JsonValue};
 use solana_client::rpc_client::RpcClient;
 use solana_client::rpc_config::RpcSendTransactionConfig;
 use solana_program::instruction::{AccountMeta, Instruction};
@@ -267,6 +268,16 @@ pub enum Command {
         #[clap(required = false, last = true)]
         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)]
@@ -471,6 +482,11 @@ pub fn entry(opts: Opts) -> Result<()> {
             skip_lint,
             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(())
 }
 
+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 {
     Stdout,
     File(PathBuf),

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

@@ -20,6 +20,7 @@ FLAGS:
     -V, --version    Prints version information
 
 SUBCOMMANDS:
+    account    Fetch and deserialize an account using the IDL provided
     build      Builds the workspace
     cluster    Cluster commands
     deploy     Deploys each program in the workspace
@@ -37,6 +38,28 @@ SUBCOMMANDS:
                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
 
 ```shell

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

@@ -240,6 +240,8 @@ impl std::str::FromStr for IdlType {
             "f64" => IdlType::F64,
             "u128" => IdlType::U128,
             "i128" => IdlType::I128,
+            "u256" => IdlType::U256,
+            "i256" => IdlType::I256,
             "Vec<u8>" => IdlType::Bytes,
             "String" | "&str" | "&'staticstr" => IdlType::String,
             "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"
   },
   "workspaces": [
+    "anchor-cli-account",
     "anchor-cli-idl",
     "cashiers-check",
     "cfo",