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>
This commit is contained in:
Adithya Narayan 2022-12-12 21:41:26 +05:30 committed by GitHub
parent 03eff348db
commit b662ff1460
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 482 additions and 1 deletions

View File

@ -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/

View File

@ -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/

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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,

View File

@ -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]

View File

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

View File

@ -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"
}
}

View File

@ -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"

View File

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

View File

@ -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>,
}

View File

@ -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"
);
}
});
});

View File

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

View File

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