diff --git a/.github/workflows/reusable-tests.yaml b/.github/workflows/reusable-tests.yaml index 4cc8f66880..2d45d098f8 100644 --- a/.github/workflows/reusable-tests.yaml +++ b/.github/workflows/reusable-tests.yaml @@ -505,6 +505,8 @@ jobs: path: tests/lazy-account - cmd: cd tests/test-instruction-validation && ./test.sh path: tests/test-instruction-validation + - cmd: cd tests/account-generation-test && anchor test + path: tests/account-generation-test steps: - uses: actions/checkout@v3 diff --git a/cli/src/config.rs b/cli/src/config.rs index 9959cfc640..ded01b6048 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -1332,6 +1332,45 @@ pub struct AccountDirEntry { pub directory: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FundedAccount { + // Base58 pubkey string of the account to fund + pub address: String, + // Amount of lamports to fund the account with (default: 1 SOL = 1_000_000_000 lamports) + #[serde(skip_serializing_if = "Option::is_none")] + pub lamports: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenMint { + // Base58 pubkey string of the mint account, or "new" to generate a random keypair + pub address: String, + // Number of base 10 digits to the right of the decimal place (required) + pub decimals: u8, + // Initial supply of tokens (default: 0) + #[serde(skip_serializing_if = "Option::is_none")] + pub supply: Option, + // Optional mint authority (default: None = fixed supply) + #[serde(skip_serializing_if = "Option::is_none")] + pub mint_authority: Option, + // Optional freeze authority (default: None = no freeze authority) + #[serde(skip_serializing_if = "Option::is_none")] + pub freeze_authority: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenAccount { + // Reference to mint (pubkey string or "new" to use the most recently created mint) + pub mint: String, + // Owner of the token account ("new" to generate random keypair, or specific pubkey) + pub owner: String, + // Amount of tokens to fund the account with + pub amount: u64, + // Optional: specific token account address (default: generate new) + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct _Validator { // Load an account from the provided JSON file @@ -1340,6 +1379,15 @@ pub struct _Validator { // Load all the accounts from the JSON files found in the specified DIRECTORY #[serde(skip_serializing_if = "Option::is_none")] pub account_dir: Option>, + // Generate and fund accounts with lamports + #[serde(skip_serializing_if = "Option::is_none")] + pub fund_accounts: Option>, + // Create SPL token mints + #[serde(skip_serializing_if = "Option::is_none")] + pub mints: Option>, + // Create and fund SPL token accounts + #[serde(skip_serializing_if = "Option::is_none")] + pub token_accounts: Option>, // IP address to bind the validator ports. [default: 127.0.0.1] #[serde(skip_serializing_if = "Option::is_none")] pub bind_address: Option, @@ -1396,6 +1444,12 @@ pub struct Validator { pub account: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub account_dir: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fund_accounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mints: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_accounts: Option>, pub bind_address: String, #[serde(skip_serializing_if = "Option::is_none")] pub clone: Option>, @@ -1432,6 +1486,9 @@ impl From<_Validator> for Validator { Self { account: _validator.account, account_dir: _validator.account_dir, + fund_accounts: _validator.fund_accounts, + mints: _validator.mints, + token_accounts: _validator.token_accounts, bind_address: _validator .bind_address .unwrap_or_else(|| DEFAULT_BIND_ADDRESS.to_string()), @@ -1461,6 +1518,9 @@ impl From for _Validator { Self { account: validator.account, account_dir: validator.account_dir, + fund_accounts: validator.fund_accounts, + mints: validator.mints, + token_accounts: validator.token_accounts, bind_address: Some(validator.bind_address), clone: validator.clone, dynamic_port_range: validator.dynamic_port_range, @@ -1529,6 +1589,62 @@ impl Merge for _Validator { } }, }, + fund_accounts: match self.fund_accounts.take() { + None => other.fund_accounts, + Some(mut entries) => match other.fund_accounts { + None => Some(entries), + Some(other_entries) => { + for other_entry in other_entries { + match entries + .iter() + .position(|my_entry| *my_entry.address == other_entry.address) + { + None => entries.push(other_entry), + Some(i) => entries[i] = other_entry, + }; + } + Some(entries) + } + }, + }, + mints: match self.mints.take() { + None => other.mints, + Some(mut entries) => match other.mints { + None => Some(entries), + Some(other_entries) => { + for other_entry in other_entries { + match entries + .iter() + .position(|my_entry| *my_entry.address == other_entry.address) + { + None => entries.push(other_entry), + Some(i) => entries[i] = other_entry, + }; + } + Some(entries) + } + }, + }, + token_accounts: match self.token_accounts.take() { + None => other.token_accounts, + Some(mut entries) => match other.token_accounts { + None => Some(entries), + Some(other_entries) => { + // For token accounts, we merge by mint+owner combination + for other_entry in other_entries { + match entries.iter().position(|my_entry| { + *my_entry.mint == other_entry.mint + && *my_entry.owner == other_entry.owner + && my_entry.address == other_entry.address + }) { + None => entries.push(other_entry), + Some(i) => entries[i] = other_entry, + }; + } + Some(entries) + } + }, + }, bind_address: other.bind_address.or_else(|| self.bind_address.take()), clone: match self.clone.take() { None => other.clone, @@ -1797,6 +1913,70 @@ mod tests { assert!(!config.features.skip_lint); } + #[test] + fn parse_fund_accounts_config() { + let config_str = r#" + [provider] + cluster = "localnet" + wallet = "id.json" + + [test.validator] + [[test.validator.fund_accounts]] + address = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + lamports = 2000000000 + + [[test.validator.fund_accounts]] + address = "GjJyeC1rB1hL8ZkLqKqJzJzJzJzJzJzJzJzJzJzJzJzJz" + "#; + + let config = Config::from_str(config_str).unwrap(); + assert!(config.test_validator.is_some()); + let test_validator = config.test_validator.as_ref().unwrap(); + assert!(test_validator.validator.is_some()); + let validator = test_validator.validator.as_ref().unwrap(); + assert!(validator.fund_accounts.is_some()); + + let fund_accounts = validator.fund_accounts.as_ref().unwrap(); + assert_eq!(fund_accounts.len(), 2); + assert_eq!( + fund_accounts[0].address, + "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + ); + assert_eq!(fund_accounts[0].lamports, Some(2000000000)); + assert_eq!( + fund_accounts[1].address, + "GjJyeC1rB1hL8ZkLqKqJzJzJzJzJzJzJzJzJzJzJzJzJz" + ); + assert_eq!(fund_accounts[1].lamports, None); // Should default to 1 SOL + } + + #[test] + fn parse_fund_accounts_without_lamports() { + let config_str = r#" + [provider] + cluster = "localnet" + wallet = "id.json" + + [test.validator] + [[test.validator.fund_accounts]] + address = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + "#; + + let config = Config::from_str(config_str).unwrap(); + let fund_accounts = config + .test_validator + .as_ref() + .unwrap() + .validator + .as_ref() + .unwrap() + .fund_accounts + .as_ref() + .unwrap(); + assert_eq!(fund_accounts.len(), 1); + assert_eq!(fund_accounts[0].lamports, None); + } + #[test] fn parse_skip_lint_no_value() { let string = BASE_CONFIG.to_owned() + "[features]"; diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 74854d1f94..839e45c385 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -2,8 +2,8 @@ use { crate::config::{ get_default_ledger_path, BootstrapMode, BuildConfig, Config, ConfigOverride, HookType, Manifest, PackageManager, ProgramDeployment, ProgramWorkspace, ScriptsConfig, - SurfnetInfoResponse, SurfpoolConfig, TestValidator, ValidatorType, WithPath, SHUTDOWN_WAIT, - STARTUP_WAIT, SURFPOOL_HOST, + SurfnetInfoResponse, SurfpoolConfig, TestValidator, Validator, ValidatorType, WithPath, + SHUTDOWN_WAIT, STARTUP_WAIT, SURFPOOL_HOST, }, abs_path::AbsolutePath, anchor_cli_macros::AbsolutePath, @@ -16,6 +16,7 @@ use { types::{Idl, IdlArrayLen, IdlDefinedFields, IdlType, IdlTypeDefTy}, }, anyhow::{anyhow, bail, Context, Result}, + base64::{engine::general_purpose::STANDARD, Engine}, checks::{check_anchor_version, check_deps, check_idl_build_feature, check_overflow}, clap::{CommandFactory, Parser}, dirs::home_dir, @@ -4193,6 +4194,7 @@ fn run_test_suite( let flags = Some(surfpool_flags( cfg, surfpool_config, + test_validator, full_simnet_mode, skip_deploy, Some(test_suite_path.as_ref()), @@ -4200,6 +4202,8 @@ fn run_test_suite( validator_handle = Some(start_surfpool_validator( flags, surfpool_config, + test_validator, + cfg, full_simnet_mode, )?); } @@ -4297,6 +4301,278 @@ fn run_test_suite( Ok(()) } +fn rent_exempt_minimum(data_len: u64) -> u64 { + (128 + data_len) * 3480 * 2 +} + +const RENT_EPOCH_NEVER: u64 = u64::MAX; + +fn write_keypair_secure(keypair: &Keypair, path: &Path) -> Result<()> { + use std::io::Write; + let bytes = keypair.to_bytes().to_vec(); + let json = serde_json::to_string(&bytes) + .with_context(|| format!("Failed to serialize keypair for {}", path.display()))?; + + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts + .open(path) + .with_context(|| format!("Failed to create keypair file: {}", path.display()))?; + file.write_all(json.as_bytes()) + .with_context(|| format!("Failed to write keypair file: {}", path.display()))?; + Ok(()) +} + +fn pack_coption_pubkey(buf: &mut Vec, value: Option) { + match value { + Some(pk) => { + buf.extend_from_slice(&1u32.to_le_bytes()); + buf.extend_from_slice(pk.as_ref()); + } + None => { + buf.extend_from_slice(&0u32.to_le_bytes()); + buf.extend_from_slice(&[0u8; 32]); + } + } +} + +fn write_account_json(path: &Path, value: &JsonValue) -> Result<()> { + let mut file = File::create(path) + .with_context(|| format!("Failed to create account file: {}", path.display()))?; + serde_json::to_writer_pretty(&mut file, value) + .with_context(|| format!("Failed to write account JSON to: {}", path.display()))?; + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GeneratedAccountKind { + FundedAccount, + Mint, + TokenAccount, +} + +#[derive(Debug, Clone)] +struct GeneratedAccount { + pubkey: Pubkey, + file_path: PathBuf, + kind: GeneratedAccountKind, +} + +fn materialize_validator_accounts( + cfg: &WithPath, + validator: &Validator, +) -> Result> { + let mut out = Vec::new(); + let needs_dir = validator.mints.is_some() + || validator.token_accounts.is_some() + || validator.fund_accounts.is_some(); + if !needs_dir { + return Ok(out); + } + + let workspace_root = cfg + .path() + .parent() + .ok_or_else(|| anyhow!("Anchor.toml path has no parent directory"))?; + let accounts_dir = workspace_root.join(".anchor").join("generated_accounts"); + fs::create_dir_all(&accounts_dir).with_context(|| { + format!( + "Failed to create accounts directory: {}", + accounts_dir.display() + ) + })?; + + let mut seen_pubkeys: HashSet = HashSet::new(); + let mut record_pubkey = |pk: Pubkey, section: &str| -> Result<()> { + if !seen_pubkeys.insert(pk) { + bail!( + "Duplicate pubkey {} across [test.validator] sections (collision detected in \ + `{}`). Each generated account must have a unique address.", + pk, + section + ); + } + Ok(()) + }; + + let mut created_mints: Vec = Vec::new(); + + if let Some(mints) = &validator.mints { + for token_mint in mints { + let pubkey = if token_mint.address.to_lowercase() == "new" { + let keypair = Keypair::new(); + let pubkey = keypair.pubkey(); + let keypair_path = accounts_dir.join(format!("{}.mint.json", pubkey)); + write_keypair_secure(&keypair, &keypair_path)?; + pubkey + } else { + Pubkey::try_from(token_mint.address.as_str()) + .map_err(|_| anyhow!("Invalid mint pubkey address: {}", token_mint.address))? + }; + record_pubkey(pubkey, "mints")?; + created_mints.push(pubkey); + + let parse_authority = |opt: &Option, field: &str| -> Result> { + opt.as_ref() + .map(|s| { + Pubkey::try_from(s.as_str()).map_err(|_| { + anyhow!("Invalid {} pubkey for mint {}: {}", field, pubkey, s) + }) + }) + .transpose() + }; + let mint_authority = parse_authority(&token_mint.mint_authority, "mint_authority")?; + let freeze_authority = + parse_authority(&token_mint.freeze_authority, "freeze_authority")?; + + let mut data = Vec::with_capacity(82); + pack_coption_pubkey(&mut data, mint_authority); + data.extend_from_slice(&token_mint.supply.unwrap_or(0).to_le_bytes()); + data.push(token_mint.decimals); + data.push(1u8); // is_initialized + pack_coption_pubkey(&mut data, freeze_authority); + + let account_json = json!({ + "pubkey": pubkey.to_string(), + "account": { + "lamports": rent_exempt_minimum(82), + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "executable": false, + "rentEpoch": RENT_EPOCH_NEVER, + "data": [STANDARD.encode(&data), "base64"] + } + }); + let file_path = accounts_dir.join(format!("{}.json", pubkey)); + write_account_json(&file_path, &account_json)?; + out.push(GeneratedAccount { + pubkey, + file_path, + kind: GeneratedAccountKind::Mint, + }); + } + } + + if let Some(token_accounts) = &validator.token_accounts { + for token_account in token_accounts { + let mint_pubkey = if token_account.mint.to_lowercase() == "new" { + *created_mints.last().ok_or_else(|| { + anyhow!( + "token_account specifies `mint = \"new\"` but no [[test.validator.mints]] \ + entries are configured" + ) + })? + } else { + Pubkey::try_from(token_account.mint.as_str()).map_err(|_| { + anyhow!( + "Invalid mint pubkey in token_account: {}", + token_account.mint + ) + })? + }; + + let owner_pubkey = if token_account.owner.to_lowercase() == "new" { + let kp = Keypair::new(); + let pk = kp.pubkey(); + let owner_path = accounts_dir.join(format!("{}.owner.json", pk)); + write_keypair_secure(&kp, &owner_path)?; + pk + } else { + Pubkey::try_from(token_account.owner.as_str()).map_err(|_| { + anyhow!( + "Invalid owner pubkey in token_account: {}", + token_account.owner + ) + })? + }; + + let token_account_pubkey = match &token_account.address { + Some(addr) if addr.to_lowercase() != "new" => Pubkey::try_from(addr.as_str()) + .map_err(|_| anyhow!("Invalid token_account address pubkey: {}", addr))?, + _ => { + let kp = Keypair::new(); + let pk = kp.pubkey(); + let ta_path = accounts_dir.join(format!("{}.token_account.json", pk)); + write_keypair_secure(&kp, &ta_path)?; + pk + } + }; + record_pubkey(token_account_pubkey, "token_accounts")?; + + let mut data = Vec::with_capacity(165); + data.extend_from_slice(mint_pubkey.as_ref()); + data.extend_from_slice(owner_pubkey.as_ref()); + data.extend_from_slice(&token_account.amount.to_le_bytes()); + pack_coption_pubkey(&mut data, None); // delegate + data.push(1u8); // state = initialized + data.extend_from_slice(&0u32.to_le_bytes()); // is_native None tag + data.extend_from_slice(&[0u8; 8]); // is_native body + data.extend_from_slice(&0u64.to_le_bytes()); // delegated_amount + pack_coption_pubkey(&mut data, None); // close_authority + + let account_json = json!({ + "pubkey": token_account_pubkey.to_string(), + "account": { + "lamports": rent_exempt_minimum(165), + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "executable": false, + "rentEpoch": RENT_EPOCH_NEVER, + "data": [STANDARD.encode(&data), "base64"] + } + }); + let file_path = accounts_dir.join(format!("{}.json", token_account_pubkey)); + write_account_json(&file_path, &account_json)?; + out.push(GeneratedAccount { + pubkey: token_account_pubkey, + file_path, + kind: GeneratedAccountKind::TokenAccount, + }); + } + } + + if let Some(fund_accounts) = &validator.fund_accounts { + for funded_account in fund_accounts { + let pubkey = if funded_account.address.to_lowercase() == "new" { + let keypair = Keypair::new(); + let pubkey = keypair.pubkey(); + let keypair_path = accounts_dir.join(format!("{}.keypair.json", pubkey)); + write_keypair_secure(&keypair, &keypair_path)?; + pubkey + } else { + Pubkey::try_from(funded_account.address.as_str()) + .map_err(|_| anyhow!("Invalid pubkey address: {}", funded_account.address))? + }; + record_pubkey(pubkey, "fund_accounts")?; + + let lamports = funded_account.lamports.unwrap_or(1_000_000_000); + + let account_json = json!({ + "pubkey": pubkey.to_string(), + "account": { + "lamports": lamports, + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": RENT_EPOCH_NEVER, + "data": ["", "base64"] + } + }); + let file_path = accounts_dir.join(format!("{}.json", pubkey)); + write_account_json(&file_path, &account_json)?; + out.push(GeneratedAccount { + pubkey, + file_path, + kind: GeneratedAccountKind::FundedAccount, + }); + } + } + + Ok(out) +} + // Returns the solana-test-validator flags. This will embed the workspace // programs in the genesis block so we don't have to deploy every time. It also // allows control of other solana-test-validator features. @@ -4369,6 +4645,12 @@ fn validator_flags( } } if let Some(validator) = &test.validator { + for acct in materialize_validator_accounts(cfg, validator)? { + flags.push("--account".to_string()); + flags.push(acct.pubkey.to_string()); + flags.push(acct.file_path.display().to_string()); + } + let entries = serde_json::to_value(validator)?; for (key, value) in entries.as_object().unwrap() { if key == "ledger" { @@ -4376,6 +4658,9 @@ fn validator_flags( // these validator flags. continue; }; + if key == "fund_accounts" || key == "mints" || key == "token_accounts" { + continue; + } if key == "account" { for entry in value.as_array().unwrap() { // Push the account flag for each array entry @@ -4474,6 +4759,7 @@ fn validator_flags( fn surfpool_flags( cfg: &WithPath, surfpool_config: &Option, + test_validator: &Option, full_simnet_mode: bool, skip_deploy: bool, test_suite_path: Option<&Path>, @@ -4497,6 +4783,17 @@ fn surfpool_flags( } } + if let Some(test) = test_validator.as_ref() { + if let Some(validator) = &test.validator { + for acct in materialize_validator_accounts(cfg, validator)? { + if matches!(acct.kind, GeneratedAccountKind::FundedAccount) { + flags.push("--airdrop".to_string()); + flags.push(acct.pubkey.to_string()); + } + } + } + } + if let Some(config) = &surfpool_config { if let Some(airdrop_addresses) = &config.airdrop_addresses { for address in airdrop_addresses { @@ -4790,6 +5087,8 @@ fn stream_solana_logs(config: &WithPath, rpc_url: &str) -> Result>, surfpool_config: &Option, + test_validator: &Option, + cfg: &WithPath, full_simnet_mode: bool, ) -> Result { let (host, port) = match surfpool_config { @@ -4874,6 +5173,68 @@ fn start_surfpool_validator( std::process::exit(1); } + if let Some(test) = test_validator.as_ref() { + if let Some(validator) = &test.validator { + if let Some(fund_accounts) = &validator.fund_accounts { + let workspace_root = cfg.path().parent().expect("Invalid Anchor.toml path"); + let accounts_dir = workspace_root.join(".anchor").join("generated_accounts"); + let _ = fs::create_dir_all(&accounts_dir); + + for funded_account in fund_accounts { + let pubkey = if funded_account.address.to_lowercase() == "new" { + let mut keypair_files: Vec<_> = match fs::read_dir(&accounts_dir) { + Ok(dir) => dir + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + let file_name = path.file_name()?.to_string_lossy(); + if file_name.ends_with(".keypair.json") { + entry.metadata().ok().and_then(|m| { + m.modified().ok().map(|modified| (modified, path)) + }) + } else { + None + } + }) + .collect(), + Err(_) => Vec::new(), + }; + keypair_files.sort_by_key(|f| std::cmp::Reverse(f.0)); + + match keypair_files.first() { + Some((_, path)) => { + match path + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.strip_suffix(".keypair")) + .and_then(|s| s.parse::().ok()) + { + Some(p) => p, + None => continue, + } + } + None => continue, + } + } else { + match Pubkey::try_from(funded_account.address.as_str()) { + Ok(p) => p, + Err(_) => continue, + } + }; + + let target_lamports = funded_account.lamports.unwrap_or(1_000_000_000); + let current_balance = client.get_balance(&pubkey).unwrap_or(0); + + if current_balance < target_lamports { + if let Ok(sig) = client.request_airdrop(&pubkey, target_lamports) { + let _ = client.confirm_transaction(&sig); + } + } + } + } + } + } + loop { let resp = client .send::>( @@ -5720,6 +6081,7 @@ fn localnet( let flags = Some(surfpool_flags( cfg, &cfg.surfpool_config, + &cfg.test_validator, full_simnet_mode, skip_deploy, None, @@ -5727,6 +6089,8 @@ fn localnet( Some(start_surfpool_validator( flags, &cfg.surfpool_config, + &cfg.test_validator, + cfg, full_simnet_mode, )?) } diff --git a/tests/account-generation-test/Anchor.toml b/tests/account-generation-test/Anchor.toml new file mode 100644 index 0000000000..67b6fb3936 --- /dev/null +++ b/tests/account-generation-test/Anchor.toml @@ -0,0 +1,58 @@ +[programs.localnet] +account_generation_test = "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" + +[test] +startup_wait = 10000 + +[test.validator] +[[test.validator.fund_accounts]] +address = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" +lamports = 2000000000 + +[[test.validator.fund_accounts]] +address = "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" + +[[test.validator.fund_accounts]] +address = "new" +lamports = 5000000000 + +[[test.validator.mints]] +address = "new" +decimals = 9 +supply = 1000000000 + +[[test.validator.mints]] +address = "new" +decimals = 6 +supply = 500000000 +mint_authority = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" +freeze_authority = "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" + +[[test.validator.mints]] +address = "new" +decimals = 8 + +[[test.validator.token_accounts]] +mint = "new" +owner = "new" +amount = 500000000 + +[[test.validator.token_accounts]] +mint = "new" +owner = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" +amount = 1000000000 + +[[test.validator.token_accounts]] +mint = "new" +owner = "new" +address = "new" +amount = 250000000 + +[features] diff --git a/tests/account-generation-test/Cargo.toml b/tests/account-generation-test/Cargo.toml new file mode 100644 index 0000000000..34900c70ea --- /dev/null +++ b/tests/account-generation-test/Cargo.toml @@ -0,0 +1,8 @@ +[profile.release] +overflow-checks = true + +[workspace] +members = [ + "programs/*" +] +resolver = "2" \ No newline at end of file diff --git a/tests/account-generation-test/package.json b/tests/account-generation-test/package.json new file mode 100644 index 0000000000..26575ee2a9 --- /dev/null +++ b/tests/account-generation-test/package.json @@ -0,0 +1,22 @@ +{ + "name": "account-generation-test", + "version": "0.1.0", + "license": "(MIT OR Apache-2.0)", + "homepage": "https://github.com/solana-foundation/anchor#readme", + "bugs": { + "url": "https://github.com/solana-foundation/anchor/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/solana-foundation/anchor.git" + }, + "engines": { + "node": ">=17" + }, + "scripts": { + "test": "anchor test" + }, + "dependencies": { + "ts-mocha": "^11.1.0" + } +} diff --git a/tests/account-generation-test/programs/account-generation-test/Cargo.toml b/tests/account-generation-test/programs/account-generation-test/Cargo.toml new file mode 100644 index 0000000000..fbaf4b5a44 --- /dev/null +++ b/tests/account-generation-test/programs/account-generation-test/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "account-generation-test" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "account_generation_test" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] +default = [] + +[dependencies] +anchor-lang = { path = "../../../../lang", features = ["init-if-needed"] } diff --git a/tests/account-generation-test/programs/account-generation-test/Xargo.toml b/tests/account-generation-test/programs/account-generation-test/Xargo.toml new file mode 100644 index 0000000000..81e1c881db --- /dev/null +++ b/tests/account-generation-test/programs/account-generation-test/Xargo.toml @@ -0,0 +1 @@ +[target.sbf-solana-solana.dependencies] diff --git a/tests/account-generation-test/programs/account-generation-test/src/lib.rs b/tests/account-generation-test/programs/account-generation-test/src/lib.rs new file mode 100644 index 0000000000..dc47099c15 --- /dev/null +++ b/tests/account-generation-test/programs/account-generation-test/src/lib.rs @@ -0,0 +1,28 @@ +use anchor_lang::prelude::*; + +declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"); + +#[program] +pub mod account_generation_test { + use super::*; + + pub fn initialize(ctx: Context) -> Result<()> { + let my_account = &mut ctx.accounts.my_account; + my_account.data = 42; + Ok(()) + } +} + +#[account] +pub struct MyAccount { + pub data: u64, +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(init, payer = user, space = 8 + 8)] + pub my_account: Account<'info, MyAccount>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} diff --git a/tests/account-generation-test/tests/account-generation-test.ts b/tests/account-generation-test/tests/account-generation-test.ts new file mode 100644 index 0000000000..3e13a5a363 --- /dev/null +++ b/tests/account-generation-test/tests/account-generation-test.ts @@ -0,0 +1,456 @@ +import * as anchor from "@anchor-lang/core"; +import { Program } from "@anchor-lang/core"; +import { AccountGenerationTest } from "../target/types/account_generation_test"; +import { assert } from "chai"; +import { PublicKey } from "@solana/web3.js"; + +describe("account-generation-test", () => { + anchor.setProvider(anchor.AnchorProvider.env()); + + const program = anchor.workspace + .AccountGenerationTest as Program; + const provider = anchor.getProvider() as anchor.AnchorProvider; + + const FUNDED_ACCOUNT_1 = new PublicKey( + "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + ); + const FUNDED_ACCOUNT_2 = new PublicKey( + "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" + ); + + it("Funded accounts should have correct lamports", async () => { + const account1Info = await provider.connection.getAccountInfo( + FUNDED_ACCOUNT_1 + ); + assert.isNotNull(account1Info, "Funded account 1 should exist"); + + assert.isTrue( + account1Info!.lamports >= 2000000000, + `Account 1 should have at least 2 SOL (has ${ + account1Info!.lamports + } lamports). Note: Surfpool uses default airdrop amounts.` + ); + + const account2Info = await provider.connection.getAccountInfo( + FUNDED_ACCOUNT_2 + ); + assert.isNotNull(account2Info, "Funded account 2 should exist"); + + assert.isTrue( + account2Info!.lamports >= 1000000000, + `Account 2 should have at least 1 SOL (has ${ + account2Info!.lamports + } lamports). Note: Surfpool uses default airdrop amounts.` + ); + }); + + it("Funded accounts should be usable for transactions", async () => { + const account1Info = await provider.connection.getAccountInfo( + FUNDED_ACCOUNT_1 + ); + assert.isNotNull(account1Info, "Funded account should exist"); + assert.isTrue( + account1Info!.lamports > 0, + "Funded account should have lamports" + ); + + assert.equal( + account1Info!.owner.toBase58(), + "11111111111111111111111111111111", + "Account should be owned by system program" + ); + }); + + it("Provider wallet should be funded by validator", async () => { + const walletBalance = await provider.connection.getBalance( + provider.wallet.publicKey + ); + assert.isTrue( + walletBalance > 0, + "Provider wallet should have lamports from validator" + ); + assert.isTrue( + walletBalance >= 1_000_000_000, + "Provider wallet should have at least 1 SOL" + ); + }); + + it("Generated 'new' account should exist and be funded", async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + + const files = fs.readdirSync(accountsDir); + const keypairFilesWithTimes = files + .filter( + (f: string) => + f.endsWith(".keypair.json") && + !f.endsWith(".token_account.json") && + !f.endsWith(".owner.json") && + !f.endsWith(".mint.json") && + f.length >= 56 + ) + .map((f: string) => { + const filePath = path.join(accountsDir, f); + const stats = fs.statSync(filePath); + return { name: f, mtime: stats.mtime.getTime() }; + }) + .sort((a: { mtime: number }, b: { mtime: number }) => b.mtime - a.mtime); + + assert.isTrue( + keypairFilesWithTimes.length > 0, + "Should have at least one generated keypair file for 'new' address" + ); + + const keypairFile = keypairFilesWithTimes[0].name; + const keypairPath = path.join(accountsDir, keypairFile); + const keypairData = JSON.parse(fs.readFileSync(keypairPath, "utf8")); + const pubkeyStr = keypairFile.replace(".keypair.json", ""); + const generatedPubkey = new PublicKey(pubkeyStr); + + const accountInfo = await provider.connection.getAccountInfo( + generatedPubkey + ); + assert.isNotNull(accountInfo, "Generated account should exist"); + assert.isTrue( + accountInfo!.lamports >= 5_000_000_000, + `Generated account should have at least 5 SOL (has ${ + accountInfo!.lamports + } lamports). Note: Surfpool uses default airdrop amounts.` + ); + }); + + const loadAllMints = async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + const mintFiles = fs + .readdirSync(accountsDir) + .filter((f: string) => f.endsWith(".mint.json")); + const parsed: Array<{ + pubkey: PublicKey; + owner: PublicKey; + decimals: number; + supply: bigint; + mintAuthorityIsSome: boolean; + freezeAuthorityIsSome: boolean; + raw: Buffer; + }> = []; + for (const f of mintFiles) { + const pubkey = new PublicKey(f.replace(".mint.json", "")); + const info = await provider.connection.getAccountInfo(pubkey); + if (!info || info.data.length < 82) continue; + const data = info.data; + const mintAuthorityIsSome = + Buffer.from(data.slice(0, 4)).readUInt32LE(0) === 1; + const supply = Buffer.from(data.slice(36, 44)).readBigUInt64LE(0); + const decimals = data[44]; + const freezeAuthorityIsSome = + Buffer.from(data.slice(46, 50)).readUInt32LE(0) === 1; + parsed.push({ + pubkey, + owner: info.owner, + decimals, + supply, + mintAuthorityIsSome, + freezeAuthorityIsSome, + raw: data, + }); + } + return parsed; + }; + + it("Generated mint should exist and be initialized", async () => { + const mints = await loadAllMints(); + if (mints.length === 0) { + console.log( + "Note: Mint not found on-chain. This is expected for Surfpool validator. Use --validator legacy for mint creation." + ); + return; + } + assert.equal( + mints[0].owner.toBase58(), + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "Mint should be owned by SPL Token Program" + ); + assert.isTrue( + mints[0].raw.length >= 82, + `Mint account data should be at least 82 bytes (has ${mints[0].raw.length})` + ); + }); + + it("Generated token account should exist and be initialized", async () => { + const all = await loadAllTokenAccounts(); + if (all.length === 0) { + console.log( + "Note: Token account not found on-chain. This is expected for Surfpool validator. Use --validator legacy for token account creation." + ); + return; + } + assert.isTrue( + all[0].raw.length >= 165, + `Token account data should be at least 165 bytes (has ${all[0].raw.length})` + ); + }); + + it("Should fund account with specific address and lamports", async () => { + const pubkey = new PublicKey( + "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + ); + const accountInfo = await provider.connection.getAccountInfo(pubkey); + assert.isNotNull(accountInfo); + assert.isTrue(accountInfo!.lamports >= 2_000_000_000); + }); + + it("Should fund account with specific address without lamports (defaults to 1 SOL)", async () => { + const pubkey = new PublicKey( + "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" + ); + const accountInfo = await provider.connection.getAccountInfo(pubkey); + assert.isNotNull(accountInfo); + assert.isTrue(accountInfo!.lamports >= 1_000_000_000); + }); + + it("Should create multiple mints with different configurations", async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + const files = fs.readdirSync(accountsDir); + const mintFiles = files.filter((f: string) => f.endsWith(".mint.json")); + assert.isTrue(mintFiles.length >= 3); + }); + + it("Should create mint with mint_authority and freeze_authority", async () => { + const mints = await loadAllMints(); + if (mints.length === 0) return; + const match = mints.find( + (m) => m.mintAuthorityIsSome && m.freezeAuthorityIsSome + ); + assert.isDefined( + match, + "expected a mint with both mint_authority and freeze_authority set" + ); + assert.equal( + match!.owner.toBase58(), + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ); + }); + + it("Should create mint without supply (defaults to 0)", async () => { + const mints = await loadAllMints(); + if (mints.length === 0) return; + const match = mints.find((m) => m.supply === 0n); + assert.isDefined(match, "expected a mint with supply = 0"); + assert.equal(match!.decimals, 8); + }); + + const loadAllTokenAccounts = async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + const tokenAccountFiles = fs + .readdirSync(accountsDir) + .filter((f: string) => f.endsWith(".token_account.json")); + const parsed: Array<{ + pubkey: PublicKey; + owner: PublicKey; + amount: bigint; + raw: Buffer; + }> = []; + for (const f of tokenAccountFiles) { + const pubkey = new PublicKey(f.replace(".token_account.json", "")); + const info = await provider.connection.getAccountInfo(pubkey); + if (!info) continue; + const data = info.data; + const owner = new PublicKey(data.slice(32, 64)); + const amount = Buffer.from(data.slice(64, 72)).readBigUInt64LE(0); + parsed.push({ pubkey, owner, amount, raw: data }); + } + return parsed; + }; + + it("Should create token account with mint=new owner=new", async () => { + const all = await loadAllTokenAccounts(); + if (all.length === 0) return; + const match = all.find((ta) => ta.amount === 500000000n); + assert.isDefined(match, "expected a token_account with amount=500000000"); + assert.isTrue(match!.raw.length >= 165); + }); + + it("Should create token account with mint=new owner=specific", async () => { + const all = await loadAllTokenAccounts(); + if (all.length === 0) return; + const match = all.find( + (ta) => + ta.owner.toBase58() === "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + ); + assert.isDefined(match, "expected a token_account with the specific owner"); + assert.equal(match!.amount.toString(), "1000000000"); + }); + + it("Should create token account with mint=new owner=new address=new", async () => { + const all = await loadAllTokenAccounts(); + if (all.length === 0) return; + const match = all.find((ta) => ta.amount === 250000000n); + assert.isDefined(match, "expected a token_account with amount=250000000"); + }); + + it("Should save owner keypairs when owner=new", async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + const files = fs.readdirSync(accountsDir); + const ownerFiles = files.filter((f: string) => f.endsWith(".owner.json")); + assert.isTrue(ownerFiles.length >= 2); + }); + + it("Should use most recent mint when mint=new", async () => { + const mints = await loadAllMints(); + const tokenAccounts = await loadAllTokenAccounts(); + if (mints.length === 0 || tokenAccounts.length === 0) return; + const lastMint = mints.find((m) => m.decimals === 8 && m.supply === 0n); + assert.isDefined( + lastMint, + "expected mints[2] (decimals=8, supply=0) on-chain" + ); + for (const ta of tokenAccounts) { + const taMintPubkey = new PublicKey(ta.raw.slice(0, 32)); + assert.equal(taMintPubkey.toBase58(), lastMint!.pubkey.toBase58()); + } + }); + + it("Should create accounts with correct rent-exempt lamports", async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + const files = fs.readdirSync(accountsDir); + const mintFiles = files.filter((f: string) => f.endsWith(".mint.json")); + const tokenAccountFiles = files.filter((f: string) => + f.endsWith(".token_account.json") + ); + if (mintFiles.length > 0) { + const mintFile = mintFiles[0]; + const mintPubkeyStr = mintFile.replace(".mint.json", ""); + const mintPubkey = new PublicKey(mintPubkeyStr); + const mintInfo = await provider.connection.getAccountInfo(mintPubkey); + if (mintInfo) { + assert.isTrue(mintInfo.lamports >= 1_461_600); + } + } + if (tokenAccountFiles.length > 0) { + const tokenAccountFile = tokenAccountFiles[0]; + const tokenAccountPubkeyStr = tokenAccountFile.replace( + ".token_account.json", + "" + ); + const tokenAccountPubkey = new PublicKey(tokenAccountPubkeyStr); + const tokenAccountInfo = await provider.connection.getAccountInfo( + tokenAccountPubkey + ); + if (tokenAccountInfo) { + assert.isTrue(tokenAccountInfo.lamports >= 2_039_280); + } + } + }); + + it("Should handle multiple token accounts referencing same mint", async () => { + const tokenAccounts = await loadAllTokenAccounts(); + if (tokenAccounts.length < 2) return; + const uniqueMints = new Set( + tokenAccounts.map((ta) => new PublicKey(ta.raw.slice(0, 32)).toBase58()) + ); + assert.equal( + uniqueMints.size, + 1, + "all token accounts (with mint=new) should reference the same mint" + ); + }); + + it("Should create all account JSON files", async () => { + const fs = require("fs"); + const path = require("path"); + const accountsDir = path.join( + __dirname, + "..", + ".anchor", + "generated_accounts" + ); + const files = fs.readdirSync(accountsDir); + const accountJsonFiles = files.filter( + (f: string) => + f.endsWith(".json") && + !f.endsWith(".keypair.json") && + !f.endsWith(".mint.json") && + !f.endsWith(".token_account.json") && + !f.endsWith(".owner.json") + ); + assert.isTrue(accountJsonFiles.length >= 5); + }); + + it("Should verify mint supply matches configuration", async () => { + const mints = await loadAllMints(); + if (mints.length === 0) return; + const match = mints.find((m) => m.supply === 1_000_000_000n); + assert.isDefined(match, "expected a mint with supply=1_000_000_000"); + assert.equal(match!.decimals, 9); + }); + + it("Should verify mint decimals match configuration", async () => { + const mints = await loadAllMints(); + if (mints.length === 0) return; + const match = mints.find((m) => m.decimals === 6); + assert.isDefined(match, "expected a mint with decimals=6"); + assert.equal(match!.supply.toString(), "500000000"); + }); + + it("Should support specific pubkey address for mints", async () => { + const specificMintPubkey = new PublicKey( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + const mintInfo = await provider.connection.getAccountInfo( + specificMintPubkey + ); + if (mintInfo) { + assert.equal( + mintInfo.owner.toBase58(), + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ); + assert.isTrue(mintInfo.data.length >= 82); + const decimals = mintInfo.data[44]; + assert.equal(decimals, 6); + const supplyBytes = mintInfo.data.slice(36, 44); + const supply = Buffer.from(supplyBytes).readBigUInt64LE(0); + assert.equal(supply.toString(), "1000000"); + } + }); +}); diff --git a/tests/account-generation-test/tsconfig.json b/tests/account-generation-test/tsconfig.json new file mode 100644 index 0000000000..cd5d2e3d06 --- /dev/null +++ b/tests/account-generation-test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +} diff --git a/tests/package.json b/tests/package.json index 005fb8d122..ff27726613 100644 --- a/tests/package.json +++ b/tests/package.json @@ -52,7 +52,8 @@ "multiple-suites", "multiple-suites-run-single", "bpf-upgradeable-state", - "duplicate-mutable-accounts" + "duplicate-mutable-accounts", + "account-generation-test" ], "dependencies": { "@project-serum/common": "^0.0.1-beta.3",