From df51a27a48e5d03005e841f383f407aef74e3555 Mon Sep 17 00:00:00 2001 From: Armani Ferrante Date: Wed, 9 Jun 2021 15:40:43 -0700 Subject: [PATCH] lang, ts: Account close constraint (#371) --- CHANGELOG.md | 1 + Cargo.lock | 1 + examples/misc/programs/misc/src/lib.rs | 11 +++ examples/misc/tests/misc.js | 55 ++++++++++++++- lang/derive/accounts/src/lib.rs | 1 + lang/src/common.rs | 24 +++++++ lang/src/error.rs | 2 + lang/src/lib.rs | 11 ++- lang/src/loader.rs | 9 ++- lang/src/program_account.rs | 12 +++- lang/syn/src/codegen/accounts/constraints.rs | 17 ++++- lang/syn/src/codegen/accounts/exit.rs | 20 ++++-- lang/syn/src/lib.rs | 13 +++- lang/syn/src/parser/accounts/constraints.rs | 72 +++++++++++++++++--- lang/syn/src/parser/accounts/mod.rs | 18 ++--- ts/src/error.ts | 5 ++ 16 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 lang/src/common.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f348898..65fd5515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ incremented for features. * cli: Add `--program-name` option for build command to build a single program at a time ([#362](https://github.com/project-serum/anchor/pull/362)). * cli, client: Parse custom cluster urls from str ([#369](https://github.com/project-serum/anchor/pull/369)). +* lang: Add `#[account(close = )]` constraint for closing accounts and sending the rent exemption lamports to a specified destination account ([#371](https://github.com/project-serum/anchor/pull/371)). ### Fixes diff --git a/Cargo.lock b/Cargo.lock index b44c8aa1..252d2936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "solana-client", "solana-sdk", "thiserror", + "url", ] [[package]] diff --git a/examples/misc/programs/misc/src/lib.rs b/examples/misc/programs/misc/src/lib.rs index 931d88f9..5db63fcf 100644 --- a/examples/misc/programs/misc/src/lib.rs +++ b/examples/misc/programs/misc/src/lib.rs @@ -82,6 +82,10 @@ pub mod misc { ctx.accounts.data.data = data; Ok(()) } + + pub fn test_close(_ctx: Context) -> ProgramResult { + Ok(()) + } } #[derive(Accounts)] @@ -117,6 +121,13 @@ pub struct TestStateCpi<'info> { misc2_program: AccountInfo<'info>, } +#[derive(Accounts)] +pub struct TestClose<'info> { + #[account(mut, close = sol_dest)] + data: ProgramAccount<'info, Data>, + sol_dest: AccountInfo<'info>, +} + // `my_account` is the associated token account being created. // `authority` must be a `mut` and `signer` since it will pay for the creation // of the associated token account. `state` is used as an association, i.e., one diff --git a/examples/misc/tests/misc.js b/examples/misc/tests/misc.js index 1c55ea0d..b6b38750 100644 --- a/examples/misc/tests/misc.js +++ b/examples/misc/tests/misc.js @@ -257,7 +257,60 @@ describe("misc", () => { }); it("Can use base58 strings to fetch an account", async () => { - const dataAccount = await program.account.dataI16.fetch(dataPubkey.toString()); + const dataAccount = await program.account.dataI16.fetch( + dataPubkey.toString() + ); assert.ok(dataAccount.data === -2048); }); + + it("Should fail to close an account when sending lamports to itself", async () => { + try { + await program.rpc.testClose({ + accounts: { + data: data.publicKey, + solDest: data.publicKey, + }, + }); + assert.ok(false); + } catch (err) { + const errMsg = "A close constraint was violated"; + assert.equal(err.toString(), errMsg); + assert.equal(err.msg, errMsg); + assert.equal(err.code, 151); + } + }); + + it("Can close an account", async () => { + const openAccount = await program.provider.connection.getAccountInfo( + data.publicKey + ); + assert.ok(openAccount !== null); + + let beforeBalance = ( + await program.provider.connection.getAccountInfo( + program.provider.wallet.publicKey + ) + ).lamports; + + await program.rpc.testClose({ + accounts: { + data: data.publicKey, + solDest: program.provider.wallet.publicKey, + }, + }); + + let afterBalance = ( + await program.provider.connection.getAccountInfo( + program.provider.wallet.publicKey + ) + ).lamports; + + // Retrieved rent exemption sol. + assert.ok(afterBalance > beforeBalance); + + const closedAccount = await program.provider.connection.getAccountInfo( + data.publicKey + ); + assert.ok(closedAccount === null); + }); }); diff --git a/lang/derive/accounts/src/lib.rs b/lang/derive/accounts/src/lib.rs index f6f05a56..967b1620 100644 --- a/lang/derive/accounts/src/lib.rs +++ b/lang/derive/accounts/src/lib.rs @@ -40,6 +40,7 @@ use syn::parse_macro_input; /// | `#[account(signer)]` | On raw `AccountInfo` structs. | Checks the given account signed the transaction. | /// | `#[account(mut)]` | On `AccountInfo`, `ProgramAccount` or `CpiAccount` structs. | Marks the account as mutable and persists the state transition. | /// | `#[account(init)]` | On `ProgramAccount` structs. | Marks the account as being initialized, skipping the account discriminator check. When using `init`, a `rent` `Sysvar` must be present in the `Accounts` struct. | +/// | `#[account(close = )]` | On `ProgramAccount` and `Loader` structs. | Marks the account as being closed at the end of the instruction's execution, sending the rent exemption lamports to the specified . | /// | `#[account(belongs_to = )]` | On `ProgramAccount` or `CpiAccount` structs | Checks the `target` field on the account matches the `target` field in the struct deriving `Accounts`. | /// | `#[account(has_one = )]` | On `ProgramAccount` or `CpiAccount` structs | Semantically different, but otherwise the same as `belongs_to`. | /// | `#[account(seeds = [])]` | On `AccountInfo` structs | Seeds for the program derived address an `AccountInfo` struct represents. | diff --git a/lang/src/common.rs b/lang/src/common.rs new file mode 100644 index 00000000..88d2ba2d --- /dev/null +++ b/lang/src/common.rs @@ -0,0 +1,24 @@ +use crate::error::ErrorCode; +use solana_program::account_info::AccountInfo; +use solana_program::entrypoint::ProgramResult; +use std::io::Write; + +pub fn close<'info>( + info: AccountInfo<'info>, + sol_destination: AccountInfo<'info>, +) -> ProgramResult { + // Transfer tokens from the account to the sol_destination. + let dest_starting_lamports = sol_destination.lamports(); + **sol_destination.lamports.borrow_mut() = + dest_starting_lamports.checked_add(info.lamports()).unwrap(); + **info.lamports.borrow_mut() = 0; + + // Mark the account discriminator as closed. + let mut data = info.try_borrow_mut_data()?; + let dst: &mut [u8] = &mut data; + let mut cursor = std::io::Cursor::new(dst); + cursor + .write_all(&crate::__private::CLOSED_ACCOUNT_DISCRIMINATOR) + .map_err(|_| ErrorCode::AccountDidNotSerialize)?; + Ok(()) +} diff --git a/lang/src/error.rs b/lang/src/error.rs index b2849a97..d07e5281 100644 --- a/lang/src/error.rs +++ b/lang/src/error.rs @@ -42,6 +42,8 @@ pub enum ErrorCode { ConstraintAssociated, #[msg("An associated init constraint was violated")] ConstraintAssociatedInit, + #[msg("A close constraint was violated")] + ConstraintClose, // Accounts. #[msg("The account discriminator was already set on this account")] diff --git a/lang/src/lib.rs b/lang/src/lib.rs index f1f962b4..4a33e6e8 100644 --- a/lang/src/lib.rs +++ b/lang/src/lib.rs @@ -25,6 +25,7 @@ extern crate self as anchor_lang; use bytemuck::{Pod, Zeroable}; use solana_program::account_info::AccountInfo; +use solana_program::entrypoint::ProgramResult; use solana_program::instruction::AccountMeta; use solana_program::program_error::ProgramError; use solana_program::pubkey::Pubkey; @@ -32,6 +33,7 @@ use std::io::Write; mod account_info; mod boxed; +mod common; mod context; mod cpi_account; mod cpi_state; @@ -92,7 +94,13 @@ pub trait Accounts<'info>: ToAccountMetas + ToAccountInfos<'info> + Sized { /// should be done here. pub trait AccountsExit<'info>: ToAccountMetas + ToAccountInfos<'info> { /// `program_id` is the currently executing program. - fn exit(&self, program_id: &Pubkey) -> solana_program::entrypoint::ProgramResult; + fn exit(&self, program_id: &Pubkey) -> ProgramResult; +} + +/// The close procedure to initiate garabage collection of an account, allowing +/// one to retrieve the rent exemption. +pub trait AccountsClose<'info>: ToAccountInfos<'info> { + fn close(&self, sol_destination: AccountInfo<'info>) -> ProgramResult; } /// A data structure of accounts providing a one time deserialization upon @@ -275,4 +283,5 @@ pub mod __private { } pub use crate::state::PROGRAM_STATE_SEED; + pub const CLOSED_ACCOUNT_DISCRIMINATOR: [u8; 8] = [255, 255, 255, 255, 255, 255, 255, 255]; } diff --git a/lang/src/loader.rs b/lang/src/loader.rs index 9e35cb3e..bea5e993 100644 --- a/lang/src/loader.rs +++ b/lang/src/loader.rs @@ -1,6 +1,7 @@ use crate::error::ErrorCode; use crate::{ - Accounts, AccountsExit, AccountsInit, ToAccountInfo, ToAccountInfos, ToAccountMetas, ZeroCopy, + Accounts, AccountsClose, AccountsExit, AccountsInit, ToAccountInfo, ToAccountInfos, + ToAccountMetas, ZeroCopy, }; use solana_program::account_info::AccountInfo; use solana_program::entrypoint::ProgramResult; @@ -175,6 +176,12 @@ impl<'info, T: ZeroCopy> AccountsExit<'info> for Loader<'info, T> { } } +impl<'info, T: ZeroCopy> AccountsClose<'info> for Loader<'info, T> { + fn close(&self, sol_destination: AccountInfo<'info>) -> ProgramResult { + crate::common::close(self.to_account_info(), sol_destination) + } +} + impl<'info, T: ZeroCopy> ToAccountMetas for Loader<'info, T> { fn to_account_metas(&self, is_signer: Option) -> Vec { let is_signer = is_signer.unwrap_or(self.acc_info.is_signer); diff --git a/lang/src/program_account.rs b/lang/src/program_account.rs index e3f9b31d..d2df06a5 100644 --- a/lang/src/program_account.rs +++ b/lang/src/program_account.rs @@ -1,7 +1,7 @@ use crate::error::ErrorCode; use crate::{ - AccountDeserialize, AccountSerialize, Accounts, AccountsExit, AccountsInit, CpiAccount, - ToAccountInfo, ToAccountInfos, ToAccountMetas, + AccountDeserialize, AccountSerialize, Accounts, AccountsClose, AccountsExit, AccountsInit, + CpiAccount, ToAccountInfo, ToAccountInfos, ToAccountMetas, }; use solana_program::account_info::AccountInfo; use solana_program::entrypoint::ProgramResult; @@ -120,6 +120,14 @@ impl<'info, T: AccountSerialize + AccountDeserialize + Clone> AccountsExit<'info } } +impl<'info, T: AccountSerialize + AccountDeserialize + Clone> AccountsClose<'info> + for ProgramAccount<'info, T> +{ + fn close(&self, sol_destination: AccountInfo<'info>) -> ProgramResult { + crate::common::close(self.to_account_info(), sol_destination) + } +} + impl<'info, T: AccountSerialize + AccountDeserialize + Clone> ToAccountMetas for ProgramAccount<'info, T> { diff --git a/lang/syn/src/codegen/accounts/constraints.rs b/lang/syn/src/codegen/accounts/constraints.rs index 340f26e3..1648808d 100644 --- a/lang/syn/src/codegen/accounts/constraints.rs +++ b/lang/syn/src/codegen/accounts/constraints.rs @@ -1,5 +1,5 @@ use crate::{ - CompositeField, Constraint, ConstraintAssociatedGroup, ConstraintBelongsTo, + CompositeField, Constraint, ConstraintAssociatedGroup, ConstraintBelongsTo, ConstraintClose, ConstraintExecutable, ConstraintGroup, ConstraintInit, ConstraintLiteral, ConstraintMut, ConstraintOwner, ConstraintRaw, ConstraintRentExempt, ConstraintSeeds, ConstraintSigner, ConstraintState, Field, Ty, @@ -50,6 +50,7 @@ pub fn linearize(c_group: &ConstraintGroup) -> Vec { executable, state, associated, + close, } = c_group.clone(); let mut constraints = Vec::new(); @@ -94,6 +95,9 @@ pub fn linearize(c_group: &ConstraintGroup) -> Vec { if let Some(c) = state { constraints.push(Constraint::State(c)); } + if let Some(c) = close { + constraints.push(Constraint::Close(c)); + } constraints } @@ -111,6 +115,7 @@ fn generate_constraint(f: &Field, c: &Constraint) -> proc_macro2::TokenStream { Constraint::Executable(c) => generate_constraint_executable(f, c), Constraint::State(c) => generate_constraint_state(f, c), Constraint::AssociatedGroup(c) => generate_constraint_associated(f, c), + Constraint::Close(c) => generate_constraint_close(f, c), } } @@ -126,6 +131,16 @@ pub fn generate_constraint_init(_f: &Field, _c: &ConstraintInit) -> proc_macro2: quote! {} } +pub fn generate_constraint_close(f: &Field, c: &ConstraintClose) -> proc_macro2::TokenStream { + let field = &f.ident; + let target = &c.sol_dest; + quote! { + if #field.to_account_info().key == #target.to_account_info().key { + return Err(anchor_lang::__private::ErrorCode::ConstraintClose.into()); + } + } +} + pub fn generate_constraint_mut(f: &Field, _c: &ConstraintMut) -> proc_macro2::TokenStream { let ident = &f.ident; quote! { diff --git a/lang/syn/src/codegen/accounts/exit.rs b/lang/syn/src/codegen/accounts/exit.rs index d6b25ec6..94b1c3dc 100644 --- a/lang/syn/src/codegen/accounts/exit.rs +++ b/lang/syn/src/codegen/accounts/exit.rs @@ -19,11 +19,21 @@ pub fn generate(accs: &AccountsStruct) -> proc_macro2::TokenStream { } AccountField::Field(f) => { let ident = &f.ident; - match f.constraints.is_mutable() { - false => quote! {}, - true => quote! { - anchor_lang::AccountsExit::exit(&self.#ident, program_id)?; - }, + if f.constraints.is_close() { + let close_target = &f.constraints.close.as_ref().unwrap().sol_dest; + quote! { + anchor_lang::AccountsClose::close( + &self.#ident, + self.#close_target.to_account_info(), + )?; + } + } else { + match f.constraints.is_mutable() { + false => quote! {}, + true => quote! { + anchor_lang::AccountsExit::exit(&self.#ident, program_id)?; + }, + } } } }) diff --git a/lang/syn/src/lib.rs b/lang/syn/src/lib.rs index 0163cc09..2f4833b8 100644 --- a/lang/syn/src/lib.rs +++ b/lang/syn/src/lib.rs @@ -257,6 +257,7 @@ pub struct ConstraintGroup { belongs_to: Vec, literal: Vec, raw: Vec, + close: Option, } impl ConstraintGroup { @@ -271,6 +272,10 @@ impl ConstraintGroup { pub fn is_signer(&self) -> bool { self.signer.is_some() } + + pub fn is_close(&self) -> bool { + self.close.is_some() + } } // A single account constraint *after* merging all tokens into a well formed @@ -290,6 +295,7 @@ pub enum Constraint { Executable(ConstraintExecutable), State(ConstraintState), AssociatedGroup(ConstraintAssociatedGroup), + Close(ConstraintClose), } // Constraint token is a single keyword in a `#[account()]` attribute. @@ -306,7 +312,7 @@ pub enum ConstraintToken { Seeds(Context), Executable(Context), State(Context), - AssociatedGroup(ConstraintAssociatedGroup), + Close(Context), Associated(Context), AssociatedPayer(Context), AssociatedSpace(Context), @@ -396,6 +402,11 @@ pub struct ConstraintAssociatedSpace { pub space: LitInt, } +#[derive(Debug, Clone)] +pub struct ConstraintClose { + pub sol_dest: Ident, +} + // Syntaxt context object for preserving metadata about the inner item. #[derive(Debug, Clone)] pub struct Context { diff --git a/lang/syn/src/parser/accounts/constraints.rs b/lang/syn/src/parser/accounts/constraints.rs index 20da4939..87260d13 100644 --- a/lang/syn/src/parser/accounts/constraints.rs +++ b/lang/syn/src/parser/accounts/constraints.rs @@ -1,9 +1,9 @@ use crate::{ ConstraintAssociated, ConstraintAssociatedGroup, ConstraintAssociatedPayer, - ConstraintAssociatedSpace, ConstraintAssociatedWith, ConstraintBelongsTo, ConstraintExecutable, - ConstraintGroup, ConstraintInit, ConstraintLiteral, ConstraintMut, ConstraintOwner, - ConstraintRaw, ConstraintRentExempt, ConstraintSeeds, ConstraintSigner, ConstraintState, - ConstraintToken, Context, + ConstraintAssociatedSpace, ConstraintAssociatedWith, ConstraintBelongsTo, ConstraintClose, + ConstraintExecutable, ConstraintGroup, ConstraintInit, ConstraintLiteral, ConstraintMut, + ConstraintOwner, ConstraintRaw, ConstraintRentExempt, ConstraintSeeds, ConstraintSigner, + ConstraintState, ConstraintToken, Context, Ty, }; use syn::ext::IdentExt; use syn::parse::{Error as ParseError, Parse, ParseStream, Result as ParseResult}; @@ -12,8 +12,8 @@ use syn::spanned::Spanned; use syn::token::Comma; use syn::{bracketed, Expr, Ident, LitStr, Token}; -pub fn parse(f: &syn::Field) -> ParseResult { - let mut constraints = ConstraintGroupBuilder::default(); +pub fn parse(f: &syn::Field, f_ty: Option<&Ty>) -> ParseResult { + let mut constraints = ConstraintGroupBuilder::new(f_ty); for attr in f.attrs.iter().filter(is_account) { for c in attr.parse_args_with(Punctuated::::parse_terminated)? { constraints.add(c)?; @@ -122,6 +122,12 @@ pub fn parse_token(stream: ParseStream) -> ParseResult { raw: stream.parse()?, }, )), + "close" => ConstraintToken::Close(Context::new( + span, + ConstraintClose { + sol_dest: stream.parse()?, + }, + )), _ => Err(ParseError::new(ident.span(), "Invalid attribute"))?, } } @@ -131,7 +137,8 @@ pub fn parse_token(stream: ParseStream) -> ParseResult { } #[derive(Default)] -pub struct ConstraintGroupBuilder { +pub struct ConstraintGroupBuilder<'ty> { + pub f_ty: Option<&'ty Ty>, pub init: Option>, pub mutable: Option>, pub signer: Option>, @@ -147,9 +154,31 @@ pub struct ConstraintGroupBuilder { pub associated_payer: Option>, pub associated_space: Option>, pub associated_with: Vec>, + pub close: Option>, } -impl ConstraintGroupBuilder { +impl<'ty> ConstraintGroupBuilder<'ty> { + pub fn new(f_ty: Option<&'ty Ty>) -> Self { + Self { + f_ty, + init: None, + mutable: None, + signer: None, + belongs_to: Vec::new(), + literal: Vec::new(), + raw: Vec::new(), + owner: None, + rent_exempt: None, + seeds: None, + executable: None, + state: None, + associated: None, + associated_payer: None, + associated_space: None, + associated_with: Vec::new(), + close: None, + } + } pub fn build(mut self) -> ParseResult { // Init implies mutable and rent exempt. if let Some(i) = &self.init { @@ -171,6 +200,7 @@ impl ConstraintGroupBuilder { } let ConstraintGroupBuilder { + f_ty: _, init, mutable, signer, @@ -186,6 +216,7 @@ impl ConstraintGroupBuilder { associated_payer, associated_space, associated_with, + close, } = self; // Converts Option> -> Option. @@ -221,6 +252,7 @@ impl ConstraintGroupBuilder { payer: associated_payer.map(|p| p.target.clone()), space: associated_space.map(|s| s.space.clone()), }), + close: into_inner!(close), }) } @@ -241,7 +273,7 @@ impl ConstraintGroupBuilder { ConstraintToken::AssociatedPayer(c) => self.add_associated_payer(c), ConstraintToken::AssociatedSpace(c) => self.add_associated_space(c), ConstraintToken::AssociatedWith(c) => self.add_associated_with(c), - ConstraintToken::AssociatedGroup(_) => panic!("Invariant violation"), + ConstraintToken::Close(c) => self.add_close(c), } } @@ -253,6 +285,28 @@ impl ConstraintGroupBuilder { Ok(()) } + fn add_close(&mut self, c: Context) -> ParseResult<()> { + if !matches!(self.f_ty, Some(Ty::ProgramAccount(_))) + && !matches!(self.f_ty, Some(Ty::Loader(_))) + { + return Err(ParseError::new( + c.span(), + "close must be on a ProgramAccount", + )); + } + if self.mutable.is_none() { + return Err(ParseError::new( + c.span(), + "mut must be provided before close", + )); + } + if self.close.is_some() { + return Err(ParseError::new(c.span(), "close already provided")); + } + self.close.replace(c); + Ok(()) + } + fn add_mut(&mut self, c: Context) -> ParseResult<()> { if self.mutable.is_some() { return Err(ParseError::new(c.span(), "mut already provided")); diff --git a/lang/syn/src/parser/accounts/mod.rs b/lang/syn/src/parser/accounts/mod.rs index dc156a68..fec2796a 100644 --- a/lang/syn/src/parser/accounts/mod.rs +++ b/lang/syn/src/parser/accounts/mod.rs @@ -25,24 +25,26 @@ pub fn parse(strct: &syn::ItemStruct) -> ParseResult { } pub fn parse_account_field(f: &syn::Field) -> ParseResult { - let constraints = constraints::parse(f)?; - let ident = f.ident.clone().unwrap(); let account_field = match is_field_primitive(f)? { true => { let ty = parse_ty(f)?; + let constraints = constraints::parse(f, Some(&ty))?; AccountField::Field(Field { ident, ty, constraints, }) } - false => AccountField::CompositeField(CompositeField { - ident, - constraints, - symbol: ident_string(f)?, - raw_field: f.clone(), - }), + false => { + let constraints = constraints::parse(f, None)?; + AccountField::CompositeField(CompositeField { + ident, + constraints, + symbol: ident_string(f)?, + raw_field: f.clone(), + }) + } }; Ok(account_field) } diff --git a/ts/src/error.ts b/ts/src/error.ts index 79e1222f..871deed4 100644 --- a/ts/src/error.ts +++ b/ts/src/error.ts @@ -68,6 +68,7 @@ const LangErrorCode = { ConstraintState: 148, ConstraintAssociated: 149, ConstraintAssociatedInit: 150, + ConstraintClose: 151, // Accounts. AccountDiscriminatorAlreadySet: 160, @@ -130,6 +131,10 @@ const LangErrorMessage = new Map([ LangErrorCode.ConstraintAssociatedInit, "An associated init constraint was violated", ], + [ + LangErrorCode.ConstraintClose, + "A close constraint was violated" + ], // Accounts. [