spl-transfer-hook-cli 0.3.0

Solana Program Library Transfer Hook Command-line Utility
use {
    serde::{Deserialize, Serialize},
    solana_sdk::pubkey::Pubkey,
    spl_tlv_account_resolution::{account::ExtraAccountMeta, seeds::Seed},
    std::{path::Path, str::FromStr},
    strum_macros::{EnumString, IntoStaticStr},
};

#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Access {
    is_signer: bool,
    is_writable: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, EnumString, IntoStaticStr, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
enum Role {
    Readonly,
    Writable,
    ReadonlySigner,
    WritableSigner,
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum AddressConfig {
    Pubkey(String),
    Seeds(Vec<Seed>),
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Config {
    #[serde(flatten)]
    address_config: AddressConfig,
    role: Role,
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConfigFile {
    extra_metas: Vec<Config>,
}

impl From<&Role> for Access {
    fn from(role: &Role) -> Self {
        match role {
            Role::Readonly => Access {
                is_signer: false,
                is_writable: false,
            },
            Role::Writable => Access {
                is_signer: false,
                is_writable: true,
            },
            Role::ReadonlySigner => Access {
                is_signer: true,
                is_writable: false,
            },
            Role::WritableSigner => Access {
                is_signer: true,
                is_writable: true,
            },
        }
    }
}

impl From<&Config> for ExtraAccountMeta {
    fn from(config: &Config) -> Self {
        let Access {
            is_signer,
            is_writable,
        } = Access::from(&config.role);
        match &config.address_config {
            AddressConfig::Pubkey(pubkey_string) => ExtraAccountMeta::new_with_pubkey(
                &Pubkey::from_str(pubkey_string).unwrap(),
                is_signer,
                is_writable,
            )
            .unwrap(),
            AddressConfig::Seeds(seeds) => {
                ExtraAccountMeta::new_with_seeds(seeds, is_signer, is_writable).unwrap()
            }
        }
    }
}

type ParseFn = fn(&str) -> Result<ConfigFile, String>;

fn get_parse_function(path: &Path) -> Result<ParseFn, String> {
    match path.extension().and_then(|s| s.to_str()) {
        Some("json") => Ok(|v: &str| {
            serde_json::from_str::<ConfigFile>(v).map_err(|e| format!("Unable to parse file: {e}"))
        }),
        Some("yaml") | Some("yml") => Ok(|v: &str| {
            serde_yaml::from_str::<ConfigFile>(v).map_err(|e| format!("Unable to parse file: {e}"))
        }),
        _ => Err(format!(
            "Unsupported file extension: {}. Only JSON and YAML files are supported",
            path.display()
        )),
    }
}

fn parse_config_file_arg(path_str: &str) -> Result<Vec<ExtraAccountMeta>, String> {
    let path = Path::new(path_str);
    let parse_fn = get_parse_function(path)?;
    let file =
        std::fs::read_to_string(path).map_err(|err| format!("Unable to read file: {err}"))?;
    let parsed_config_file = parse_fn(&file)?;
    Ok(parsed_config_file
        .extra_metas
        .iter()
        .map(ExtraAccountMeta::from)
        .collect())
}

fn parse_pubkey_role_arg(pubkey_string: &str, role: &str) -> Result<Vec<ExtraAccountMeta>, String> {
    let pubkey = Pubkey::from_str(pubkey_string).map_err(|e| format!("{e}"))?;
    let role = &Role::from_str(role).map_err(|e| format!("{e}"))?;
    let Access {
        is_signer,
        is_writable,
    } = role.into();
    ExtraAccountMeta::new_with_pubkey(&pubkey, is_signer, is_writable)
        .map(|meta| vec![meta])
        .map_err(|e| format!("{e}"))
}

pub fn parse_transfer_hook_account_arg(arg: &str) -> Result<Vec<ExtraAccountMeta>, String> {
    match arg.split(':').collect::<Vec<_>>().as_slice() {
        [pubkey_str, role] => parse_pubkey_role_arg(pubkey_str, role),
        _ => parse_config_file_arg(arg),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_json() {
        let config = r#"{
            "extraMetas": [
                {
                    "pubkey": "39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6",
                    "role": "readonlySigner"
                },
                {
                    "pubkey": "6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV",
                    "role": "readonly"
                },
                {
                    "seeds": [
                        {
                            "literal": {
                                "bytes": [1, 2, 3, 4, 5, 6]
                            }
                        },
                        {
                            "instructionData": {
                                "index": 0,
                                "length": 8
                            }
                        },
                        {
                            "accountKey": {
                                "index": 0
                            }
                        }
                    ],
                    "role": "writable"
                },
                {
                    "seeds": [
                        {
                            "accountData": {
                                "accountIndex": 1,
                                "dataIndex": 4,
                                "length": 4
                            }
                        },
                        {
                            "accountKey": {
                                "index": 1
                            }
                        }
                    ],
                    "role": "readonly"
                }
            ]
        }"#;
        let parsed_config_file = serde_json::from_str::<ConfigFile>(config).unwrap();
        let parsed_extra_metas: Vec<ExtraAccountMeta> = parsed_config_file
            .extra_metas
            .iter()
            .map(|config| config.into())
            .collect::<Vec<_>>();
        let expected = vec![
            ExtraAccountMeta::new_with_pubkey(
                &Pubkey::from_str("39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6").unwrap(),
                true,
                false,
            )
            .unwrap(),
            ExtraAccountMeta::new_with_pubkey(
                &Pubkey::from_str("6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV").unwrap(),
                false,
                false,
            )
            .unwrap(),
            ExtraAccountMeta::new_with_seeds(
                &[
                    Seed::Literal {
                        bytes: vec![1, 2, 3, 4, 5, 6],
                    },
                    Seed::InstructionData {
                        index: 0,
                        length: 8,
                    },
                    Seed::AccountKey { index: 0 },
                ],
                false,
                true,
            )
            .unwrap(),
            ExtraAccountMeta::new_with_seeds(
                &[
                    Seed::AccountData {
                        account_index: 1,
                        data_index: 4,
                        length: 4,
                    },
                    Seed::AccountKey { index: 1 },
                ],
                false,
                false,
            )
            .unwrap(),
        ];
        assert_eq!(parsed_extra_metas, expected);
    }

    #[test]
    fn test_parse_yaml() {
        let config = r#"
            extraMetas:
                - pubkey: "39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6"
                  role: "readonlySigner"
                - pubkey: "6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV"
                  role: "readonly"
                - seeds:
                    - literal:
                        bytes: [1, 2, 3, 4, 5, 6]
                    - instructionData:
                        index: 0
                        length: 8
                    - accountKey:
                        index: 0
                  role: "writable"
                - seeds:
                    - accountData:
                        accountIndex: 1
                        dataIndex: 4
                        length: 4
                    - accountKey:
                        index: 1
                  role: "readonly"
        "#;
        let parsed_config_file = serde_yaml::from_str::<ConfigFile>(config).unwrap();
        let parsed_extra_metas: Vec<ExtraAccountMeta> = parsed_config_file
            .extra_metas
            .iter()
            .map(|config| config.into())
            .collect::<Vec<_>>();
        let expected = vec![
            ExtraAccountMeta::new_with_pubkey(
                &Pubkey::from_str("39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6").unwrap(),
                true,
                false,
            )
            .unwrap(),
            ExtraAccountMeta::new_with_pubkey(
                &Pubkey::from_str("6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV").unwrap(),
                false,
                false,
            )
            .unwrap(),
            ExtraAccountMeta::new_with_seeds(
                &[
                    Seed::Literal {
                        bytes: vec![1, 2, 3, 4, 5, 6],
                    },
                    Seed::InstructionData {
                        index: 0,
                        length: 8,
                    },
                    Seed::AccountKey { index: 0 },
                ],
                false,
                true,
            )
            .unwrap(),
            ExtraAccountMeta::new_with_seeds(
                &[
                    Seed::AccountData {
                        account_index: 1,
                        data_index: 4,
                        length: 4,
                    },
                    Seed::AccountKey { index: 1 },
                ],
                false,
                false,
            )
            .unwrap(),
        ];
        assert_eq!(parsed_extra_metas, expected);
    }
}