From 1f31770c83d33feda11aa39c249f2efdd5bc4ab3 Mon Sep 17 00:00:00 2001 From: Armani Ferrante Date: Sun, 7 Feb 2021 23:45:10 +0800 Subject: [PATCH 1/2] Program interfaces --- .travis.yml | 1 + CHANGELOG.md | 6 + Cargo.lock | 13 ++ examples/interface/Anchor.toml | 2 + examples/interface/Cargo.toml | 4 + .../programs/counter-auth/Cargo.toml | 19 ++ .../programs/counter-auth/Xargo.toml | 2 + .../programs/counter-auth/src/lib.rs | 43 +++++ .../interface/programs/counter/Cargo.toml | 18 ++ .../interface/programs/counter/Xargo.toml | 2 + .../interface/programs/counter/src/lib.rs | 73 ++++++++ examples/interface/tests/interface.js | 45 +++++ lang/Cargo.toml | 1 + lang/attribute/interface/Cargo.toml | 19 ++ lang/attribute/interface/src/lib.rs | 120 ++++++++++++ lang/attribute/program/src/lib.rs | 4 +- lang/src/context.rs | 12 +- lang/src/lib.rs | 12 +- lang/src/vec.rs | 19 ++ lang/syn/src/codegen/error.rs | 7 + lang/syn/src/codegen/program.rs | 174 +++++++++++++++++- lang/syn/src/lib.rs | 9 + lang/syn/src/parser/program.rs | 94 +++++++++- ts/package.json | 4 +- ts/src/coder.ts | 4 +- ts/yarn.lock | 9 +- 26 files changed, 692 insertions(+), 24 deletions(-) create mode 100644 examples/interface/Anchor.toml create mode 100644 examples/interface/Cargo.toml create mode 100644 examples/interface/programs/counter-auth/Cargo.toml create mode 100644 examples/interface/programs/counter-auth/Xargo.toml create mode 100644 examples/interface/programs/counter-auth/src/lib.rs create mode 100644 examples/interface/programs/counter/Cargo.toml create mode 100644 examples/interface/programs/counter/Xargo.toml create mode 100644 examples/interface/programs/counter/src/lib.rs create mode 100644 examples/interface/tests/interface.js create mode 100644 lang/attribute/interface/Cargo.toml create mode 100644 lang/attribute/interface/src/lib.rs create mode 100644 lang/src/vec.rs diff --git a/.travis.yml b/.travis.yml index 5bd9537442..e4f6fd71fe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -49,6 +49,7 @@ jobs: - pushd examples/errors && anchor test && popd - pushd examples/spl/token-proxy && anchor test && popd - pushd examples/multisig && anchor test && popd + - pushd examples/interface && anchor test && popd - pushd examples/tutorial/basic-0 && anchor test && popd - pushd examples/tutorial/basic-1 && anchor test && popd - pushd examples/tutorial/basic-2 && anchor test && popd diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d26c3369e..0f0d5fd198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ incremented for features. ## [Unreleased] +### Features + +* lang: Adds the ability to create and use CPI program interfaces [(#66)](https://github.com/project-serum/anchor/pull/66/files?file-filters%5B%5D=). + +### Breaking Changes + * lang, client, ts: Migrate from rust enum based method dispatch to a variant of sighash [(#64)](https://github.com/project-serum/anchor/pull/64). ## [0.1.0] - 2021-01-31 diff --git a/Cargo.lock b/Cargo.lock index fc4693f821..c59e133dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,18 @@ dependencies = [ "syn 1.0.57", ] +[[package]] +name = "anchor-attribute-interface" +version = "0.1.0" +dependencies = [ + "anchor-syn", + "anyhow", + "heck", + "proc-macro2 1.0.24", + "quote 1.0.8", + "syn 1.0.57", +] + [[package]] name = "anchor-attribute-program" version = "0.1.0" @@ -155,6 +167,7 @@ dependencies = [ "anchor-attribute-access-control", "anchor-attribute-account", "anchor-attribute-error", + "anchor-attribute-interface", "anchor-attribute-program", "anchor-attribute-state", "anchor-derive-accounts", diff --git a/examples/interface/Anchor.toml b/examples/interface/Anchor.toml new file mode 100644 index 0000000000..2ebd5af99b --- /dev/null +++ b/examples/interface/Anchor.toml @@ -0,0 +1,2 @@ +cluster = "localnet" +wallet = "~/.config/solana/id.json" diff --git a/examples/interface/Cargo.toml b/examples/interface/Cargo.toml new file mode 100644 index 0000000000..a60de986d3 --- /dev/null +++ b/examples/interface/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = [ + "programs/*" +] diff --git a/examples/interface/programs/counter-auth/Cargo.toml b/examples/interface/programs/counter-auth/Cargo.toml new file mode 100644 index 0000000000..fc05df1829 --- /dev/null +++ b/examples/interface/programs/counter-auth/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "counter-auth" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "counter_auth" + +[features] +no-entrypoint = [] +no-idl = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +anchor-lang = { git = "https://github.com/project-serum/anchor" } +counter = { path = "../counter", features = ["cpi"] } diff --git a/examples/interface/programs/counter-auth/Xargo.toml b/examples/interface/programs/counter-auth/Xargo.toml new file mode 100644 index 0000000000..1744f098ae --- /dev/null +++ b/examples/interface/programs/counter-auth/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/examples/interface/programs/counter-auth/src/lib.rs b/examples/interface/programs/counter-auth/src/lib.rs new file mode 100644 index 0000000000..8057455fe2 --- /dev/null +++ b/examples/interface/programs/counter-auth/src/lib.rs @@ -0,0 +1,43 @@ +//! counter-auth is an example of a program *implementing* an external program +//! interface. Here the `counter::Auth` trait, where we only allow a count +//! to be incremented if it changes the counter from odd -> even or even -> odd. +//! Creative, I know. :P. + +#![feature(proc_macro_hygiene)] + +use anchor_lang::prelude::*; +use counter::Auth; + +#[program] +pub mod counter_auth { + use super::*; + + #[state] + pub struct CounterAuth {} + + // TODO: remove this impl block after addressing + // https://github.com/project-serum/anchor/issues/71. + impl CounterAuth { + pub fn new(_ctx: Context) -> Result { + Ok(Self {}) + } + } + + impl<'info> Auth<'info, Empty> for CounterAuth { + fn is_authorized(_ctx: Context, current: u64, new: u64) -> ProgramResult { + if current % 2 == 0 { + if new % 2 == 0 { + return Err(ProgramError::Custom(50)); // Arbitrary error code. + } + } else { + if new % 2 == 1 { + return Err(ProgramError::Custom(60)); // Arbitrary error code. + } + } + Ok(()) + } + } +} + +#[derive(Accounts)] +pub struct Empty {} diff --git a/examples/interface/programs/counter/Cargo.toml b/examples/interface/programs/counter/Cargo.toml new file mode 100644 index 0000000000..fcb187759a --- /dev/null +++ b/examples/interface/programs/counter/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "counter" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "counter" + +[features] +no-entrypoint = [] +no-idl = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +anchor-lang = { git = "https://github.com/project-serum/anchor" } diff --git a/examples/interface/programs/counter/Xargo.toml b/examples/interface/programs/counter/Xargo.toml new file mode 100644 index 0000000000..1744f098ae --- /dev/null +++ b/examples/interface/programs/counter/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/examples/interface/programs/counter/src/lib.rs b/examples/interface/programs/counter/src/lib.rs new file mode 100644 index 0000000000..fea7c3f961 --- /dev/null +++ b/examples/interface/programs/counter/src/lib.rs @@ -0,0 +1,73 @@ +//! counter is an example program that depends on an external interface +//! that another program must implement. This allows our program to depend +//! on another program, without knowing anything about it other than the fact +//! that it implements the `Auth` trait. +//! +//! Here, we have a counter, where, in order to set the count, the `Auth` +//! program must first approve the transaction. + +#![feature(proc_macro_hygiene)] + +use anchor_lang::prelude::*; + +#[program] +pub mod counter { + use super::*; + + #[state] + pub struct Counter { + pub count: u64, + pub auth_program: Pubkey, + } + + impl Counter { + pub fn new(_ctx: Context, auth_program: Pubkey) -> Result { + Ok(Self { + count: 0, + auth_program, + }) + } + + #[access_control(SetCount::accounts(&self, &ctx))] + pub fn set_count(&mut self, ctx: Context, new_count: u64) -> Result<()> { + // Ask the auth program if we should approve the transaction. + let cpi_program = ctx.accounts.auth_program.clone(); + let cpi_ctx = CpiContext::new(cpi_program, Empty {}); + auth::is_authorized(cpi_ctx, self.count, new_count)?; + + // Approved, so update. + self.count = new_count; + Ok(()) + } + } +} + +#[derive(Accounts)] +pub struct Empty {} + +#[derive(Accounts)] +pub struct SetCount<'info> { + auth_program: AccountInfo<'info>, +} + +impl<'info> SetCount<'info> { + // Auxiliary account validation requiring program inputs. As a convention, + // we separate it from the business logic of the instruction handler itself. + pub fn accounts(counter: &Counter, ctx: &Context) -> Result<()> { + if ctx.accounts.auth_program.key != &counter.auth_program { + return Err(ErrorCode::InvalidAuthProgram.into()); + } + Ok(()) + } +} + +#[interface] +pub trait Auth<'info, T: Accounts<'info>> { + fn is_authorized(ctx: Context, current: u64, new: u64) -> ProgramResult; +} + +#[error] +pub enum ErrorCode { + #[msg("Invalid auth program.")] + InvalidAuthProgram, +} diff --git a/examples/interface/tests/interface.js b/examples/interface/tests/interface.js new file mode 100644 index 0000000000..7696062cd3 --- /dev/null +++ b/examples/interface/tests/interface.js @@ -0,0 +1,45 @@ +const anchor = require('@project-serum/anchor'); +const assert = require("assert"); + +describe("interface", () => { + // Configure the client to use the local cluster. + anchor.setProvider(anchor.Provider.env()); + + const counter = anchor.workspace.Counter; + const counterAuth = anchor.workspace.CounterAuth; + it("Is initialized!", async () => { + await counter.state.rpc.new(counterAuth.programId); + + const stateAccount = await counter.state(); + assert.ok(stateAccount.count.eq(new anchor.BN(0))); + assert.ok(stateAccount.authProgram.equals(counterAuth.programId)); + }); + + it("Should fail to go from even to event", async () => { + await assert.rejects( + async () => { + await counter.state.rpc.setCount(new anchor.BN(4), { + accounts: { + authProgram: counterAuth.programId, + }, + }); + }, + (err) => { + if (err.toString().split("custom program error: 0x32").length !== 2) { + return false; + } + return true; + } + ); + }); + + it("Shold succeed to go from even to odd", async () => { + await counter.state.rpc.setCount(new anchor.BN(3), { + accounts: { + authProgram: counterAuth.programId, + }, + }); + const stateAccount = await counter.state(); + assert.ok(stateAccount.count.eq(new anchor.BN(3))); + }); +}); diff --git a/lang/Cargo.toml b/lang/Cargo.toml index 9184664bbd..a6eead8ae6 100644 --- a/lang/Cargo.toml +++ b/lang/Cargo.toml @@ -17,6 +17,7 @@ anchor-attribute-account = { path = "./attribute/account", version = "0.1.0" } anchor-attribute-error = { path = "./attribute/error", version = "0.1.0" } anchor-attribute-program = { path = "./attribute/program", version = "0.1.0" } anchor-attribute-state = { path = "./attribute/state", version = "0.1.0" } +anchor-attribute-interface = { path = "./attribute/interface", version = "0.1.0" } anchor-derive-accounts = { path = "./derive/accounts", version = "0.1.0" } serum-borsh = "0.8.1-serum.1" solana-program = "=1.5.0" diff --git a/lang/attribute/interface/Cargo.toml b/lang/attribute/interface/Cargo.toml new file mode 100644 index 0000000000..07f6cb9867 --- /dev/null +++ b/lang/attribute/interface/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "anchor-attribute-interface" +version = "0.1.0" +authors = ["Serum Foundation "] +repository = "https://github.com/project-serum/anchor" +license = "Apache-2.0" +description = "Attribute for defining a program interface trait" +edition = "2018" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "=1.0.57", features = ["full"] } +anyhow = "1.0.32" +anchor-syn = { path = "../../syn", version = "0.1.0" } +heck = "0.3.2" diff --git a/lang/attribute/interface/src/lib.rs b/lang/attribute/interface/src/lib.rs new file mode 100644 index 0000000000..659d0dcd7a --- /dev/null +++ b/lang/attribute/interface/src/lib.rs @@ -0,0 +1,120 @@ +extern crate proc_macro; + +use anchor_syn::parser; +use heck::SnakeCase; +use quote::quote; +use syn::parse_macro_input; + +#[proc_macro_attribute] +pub fn interface( + _args: proc_macro::TokenStream, + input: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + let item_trait = parse_macro_input!(input as syn::ItemTrait); + + let trait_name = item_trait.ident.to_string(); + let mod_name: proc_macro2::TokenStream = item_trait + .ident + .to_string() + .to_snake_case() + .parse() + .unwrap(); + + let methods: Vec = item_trait + .items + .iter() + .filter_map(|trait_item: &syn::TraitItem| match trait_item { + syn::TraitItem::Method(m) => Some(m), + _ => None, + }) + .map(|method: &syn::TraitItemMethod| { + let method_name = &method.sig.ident; + let args: Vec<&syn::PatType> = method + .sig + .inputs + .iter() + .filter_map(|arg: &syn::FnArg| match arg { + syn::FnArg::Typed(pat_ty) => Some(pat_ty), + // TODO: just map this to None once we allow this feature. + _ => panic!("Invalid syntax. No self allowed."), + }) + .filter_map(|pat_ty: &syn::PatType| { + let mut ty = parser::tts_to_string(&pat_ty.ty); + ty.retain(|s| !s.is_whitespace()); + if ty.starts_with("Context<") { + None + } else { + Some(pat_ty) + } + }) + .collect(); + let args_no_tys: Vec<&Box> = args + .iter() + .map(|arg| { + &arg.pat + }) + .collect(); + let args_struct = { + if args.len() == 0 { + quote! { + use anchor_lang::prelude::borsh; + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + struct Args; + } + } else { + quote! { + use anchor_lang::prelude::borsh; + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + struct Args { + #(#args),* + } + } + } + }; + + let sighash_arr = anchor_syn::codegen::program::sighash(&trait_name, &method_name.to_string()); + let sighash_tts: proc_macro2::TokenStream = + format!("{:?}", sighash_arr).parse().unwrap(); + quote! { + pub fn #method_name<'a,'b, 'c, 'info, T: anchor_lang::ToAccountMetas + anchor_lang::ToAccountInfos<'info>>( + ctx: anchor_lang::CpiContext<'a, 'b, 'c, 'info, T>, + #(#args),* + ) -> anchor_lang::solana_program::entrypoint::ProgramResult { + #args_struct + + let ix = { + let ix = Args { + #(#args_no_tys),* + }; + let mut ix_data = anchor_lang::AnchorSerialize::try_to_vec(&ix) + .map_err(|_| anchor_lang::solana_program::program_error::ProgramError::InvalidInstructionData)?; + let mut data = #sighash_tts.to_vec(); + data.append(&mut ix_data); + let accounts = ctx.accounts.to_account_metas(None); + anchor_lang::solana_program::instruction::Instruction { + program_id: *ctx.program.key, + accounts, + data, + } + }; + let mut acc_infos = ctx.accounts.to_account_infos(); + acc_infos.push(ctx.program.clone()); + anchor_lang::solana_program::program::invoke_signed( + &ix, + &acc_infos, + ctx.signer_seeds, + ) + } + } + }) + .collect(); + + proc_macro::TokenStream::from(quote! { + #item_trait + + mod #mod_name { + use super::*; + #(#methods)* + } + }) +} diff --git a/lang/attribute/program/src/lib.rs b/lang/attribute/program/src/lib.rs index 45f62b9b26..6deec4bfce 100644 --- a/lang/attribute/program/src/lib.rs +++ b/lang/attribute/program/src/lib.rs @@ -4,8 +4,8 @@ use anchor_syn::codegen::program as program_codegen; use anchor_syn::parser::program as program_parser; use syn::parse_macro_input; -/// The module containing all instruction handlers defining all entries to the -/// Solana program. +/// The `#[program]` attribute defines the module containing all instruction +/// handlers defining all entries into a Solana program. #[proc_macro_attribute] pub fn program( _args: proc_macro::TokenStream, diff --git a/lang/src/context.rs b/lang/src/context.rs index 4b204a3ff2..5000250b0b 100644 --- a/lang/src/context.rs +++ b/lang/src/context.rs @@ -1,4 +1,4 @@ -use crate::Accounts; +use crate::{Accounts, ToAccountInfos, ToAccountMetas}; use solana_program::account_info::AccountInfo; use solana_program::pubkey::Pubkey; @@ -27,13 +27,19 @@ impl<'a, 'b, 'c, 'info, T: Accounts<'info>> Context<'a, 'b, 'c, 'info, T> { } /// Context speciying non-argument inputs for cross-program-invocations. -pub struct CpiContext<'a, 'b, 'c, 'info, T: Accounts<'info>> { +pub struct CpiContext<'a, 'b, 'c, 'info, T> +where + T: ToAccountMetas + ToAccountInfos<'info>, +{ pub accounts: T, pub program: AccountInfo<'info>, pub signer_seeds: &'a [&'b [&'c [u8]]], } -impl<'a, 'b, 'c, 'info, T: Accounts<'info>> CpiContext<'a, 'b, 'c, 'info, T> { +impl<'a, 'b, 'c, 'info, T> CpiContext<'a, 'b, 'c, 'info, T> +where + T: ToAccountMetas + ToAccountInfos<'info>, +{ pub fn new(program: AccountInfo<'info>, accounts: T) -> Self { Self { accounts, diff --git a/lang/src/lib.rs b/lang/src/lib.rs index d7b6d63099..d04f80de2c 100644 --- a/lang/src/lib.rs +++ b/lang/src/lib.rs @@ -39,6 +39,7 @@ pub mod idl; mod program_account; mod state; mod sysvar; +mod vec; pub use crate::context::{Context, CpiContext}; pub use crate::cpi_account::CpiAccount; @@ -49,6 +50,7 @@ pub use crate::sysvar::Sysvar; pub use anchor_attribute_access_control::access_control; pub use anchor_attribute_account::account; pub use anchor_attribute_error::error; +pub use anchor_attribute_interface::interface; pub use anchor_attribute_program::program; pub use anchor_attribute_state::state; pub use anchor_derive_accounts::Accounts; @@ -68,8 +70,8 @@ pub trait Accounts<'info>: ToAccountMetas + ToAccountInfos<'info> + Sized { /// program dependent. However, users of these types should never have to /// worry about account substitution attacks. For example, if a program /// expects a `Mint` account from the SPL token program in a particular - /// field, then it should be impossible for this method to return `Ok` if any - /// other account type is given--from the SPL token program or elsewhere. + /// field, then it should be impossible for this method to return `Ok` if + /// any other account type is given--from the SPL token program or elsewhere. /// /// `program_id` is the currently executing program. `accounts` is the /// set of accounts to construct the type from. For every account used, @@ -171,9 +173,9 @@ pub trait InstructionData: AnchorSerialize { /// All programs should include it via `anchor_lang::prelude::*;`. pub mod prelude { pub use super::{ - access_control, account, error, program, state, AccountDeserialize, AccountSerialize, - Accounts, AccountsExit, AccountsInit, AnchorDeserialize, AnchorSerialize, Context, - CpiAccount, CpiContext, Ctor, ProgramAccount, ProgramState, Sysvar, ToAccountInfo, + access_control, account, error, interface, program, state, AccountDeserialize, + AccountSerialize, Accounts, AccountsExit, AccountsInit, AnchorDeserialize, AnchorSerialize, + Context, CpiAccount, CpiContext, Ctor, ProgramAccount, ProgramState, Sysvar, ToAccountInfo, ToAccountInfos, ToAccountMetas, }; diff --git a/lang/src/vec.rs b/lang/src/vec.rs new file mode 100644 index 0000000000..4da643f23c --- /dev/null +++ b/lang/src/vec.rs @@ -0,0 +1,19 @@ +use crate::{ToAccountInfos, ToAccountMetas}; +use solana_program::account_info::AccountInfo; +use solana_program::instruction::AccountMeta; + +impl<'info, T: ToAccountInfos<'info>> ToAccountInfos<'info> for Vec { + fn to_account_infos(&self) -> Vec> { + self.iter() + .flat_map(|item| item.to_account_infos()) + .collect() + } +} + +impl ToAccountMetas for Vec { + fn to_account_metas(&self, is_signer: Option) -> Vec { + self.iter() + .flat_map(|item| (*item).to_account_metas(is_signer)) + .collect() + } +} diff --git a/lang/syn/src/codegen/error.rs b/lang/syn/src/codegen/error.rs index 4f73645e81..ebabe1c0ea 100644 --- a/lang/syn/src/codegen/error.rs +++ b/lang/syn/src/codegen/error.rs @@ -37,5 +37,12 @@ pub fn generate(error: Error) -> proc_macro2::TokenStream { } } } + + impl std::convert::From<#enum_name> for ProgramError { + fn from(e: #enum_name) -> ProgramError { + let err: Error = e.into(); + err.into() + } + } } } diff --git a/lang/syn/src/codegen/program.rs b/lang/syn/src/codegen/program.rs index ce3f8a52a6..2b068ed212 100644 --- a/lang/syn/src/codegen/program.rs +++ b/lang/syn/src/codegen/program.rs @@ -41,7 +41,7 @@ pub fn generate(program: Program) -> proc_macro2::TokenStream { if cfg!(not(feature = "no-idl")) { if sighash == anchor_lang::idl::IDL_IX_TAG.to_le_bytes() { - return __private::__idl(program_id, accounts, &instruction_data[8..]); + return __private::__idl(program_id, accounts, &instruction_data); } } @@ -66,6 +66,7 @@ pub fn generate(program: Program) -> proc_macro2::TokenStream { } pub fn generate_dispatch(program: &Program) -> proc_macro2::TokenStream { + // Dispatch the state constructor. let ctor_state_dispatch_arm = match &program.state { None => quote! { /* no-op */ }, Some(state) => { @@ -85,6 +86,8 @@ pub fn generate_dispatch(program: &Program) -> proc_macro2::TokenStream { } } }; + + // Dispatch the state impl instructions. let state_dispatch_arms: Vec = match &program.state { None => vec![], Some(s) => s @@ -112,6 +115,63 @@ pub fn generate_dispatch(program: &Program) -> proc_macro2::TokenStream { }) .collect(), }; + + // Dispatch all trait interface implementations. + let trait_dispatch_arms: Vec = match &program.state { + None => vec![], + Some(s) => s + .interfaces + .iter() + .flat_map(|iface: &crate::StateInterface| { + iface + .methods + .iter() + .map(|m: &crate::StateRpc| { + let rpc_arg_names: Vec<&syn::Ident> = + m.args.iter().map(|arg| &arg.name).collect(); + let name = &m.raw_method.sig.ident.to_string(); + let rpc_name: proc_macro2::TokenStream = format!("__{}_{}", iface.trait_name, name).parse().unwrap(); + let raw_args: Vec<&syn::PatType> = m + .args + .iter() + .map(|arg: &crate::RpcArg| &arg.raw_arg) + .collect(); + let sighash_arr = sighash(&iface.trait_name, &m.ident.to_string()); + let sighash_tts: proc_macro2::TokenStream = + format!("{:?}", sighash_arr).parse().unwrap(); + let args_struct = { + if m.args.len() == 0 { + quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + struct Args; + } + } else { + quote! { + #[derive(anchor_lang::AnchorSerialize, anchor_lang::AnchorDeserialize)] + struct Args { + #(#raw_args),* + } + } + } + }; + quote! { + #sighash_tts => { + #args_struct + let ix = Args::deserialize(&mut instruction_data) + .map_err(|_| ProgramError::Custom(1))?; // todo: error code + let Args { + #(#rpc_arg_names),* + } = ix; + __private::#rpc_name(program_id, accounts, #(#rpc_arg_names),*) + } + } + }) + .collect::>() + }) + .collect(), + }; + + // Dispatch all global instructions. let dispatch_arms: Vec = program .rpcs .iter() @@ -139,6 +199,7 @@ pub fn generate_dispatch(program: &Program) -> proc_macro2::TokenStream { match sighash { #ctor_state_dispatch_arm #(#state_dispatch_arms)* + #(#trait_dispatch_arms)* #(#dispatch_arms)* _ => { msg!("Fallback functions are not supported. If you have a use case, please file an issue."); @@ -166,7 +227,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr let mut data: &[u8] = idl_ix_data; let ix = anchor_lang::idl::IdlInstruction::deserialize(&mut data) - .map_err(|_| ProgramError::Custom(1))?; // todo + .map_err(|_| ProgramError::Custom(2))?; // todo match ix { anchor_lang::idl::IdlInstruction::Create { data_len } => { @@ -419,6 +480,101 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr }) .collect(), }; + let non_inlined_state_trait_handlers: Vec = match &program.state { + None => Vec::new(), + Some(state) => state + .interfaces + .iter() + .flat_map(|iface: &crate::StateInterface| { + iface + .methods + .iter() + .map(|rpc| { + let rpc_params: Vec<_> = rpc.args.iter().map(|arg| &arg.raw_arg).collect(); + let rpc_arg_names: Vec<&syn::Ident> = + rpc.args.iter().map(|arg| &arg.name).collect(); + let private_rpc_name: proc_macro2::TokenStream = { + let n = format!("__{}_{}", iface.trait_name, &rpc.raw_method.sig.ident.to_string()); + n.parse().unwrap() + }; + let rpc_name = &rpc.raw_method.sig.ident; + let state_ty: proc_macro2::TokenStream = state.name.parse().unwrap(); + let anchor_ident = &rpc.anchor_ident; + + if rpc.has_receiver { + quote! { + #[inline(never)] + pub fn #private_rpc_name( + program_id: &Pubkey, + accounts: &[AccountInfo], + #(#rpc_params),* + ) -> ProgramResult { + + let mut remaining_accounts: &[AccountInfo] = accounts; + if remaining_accounts.len() == 0 { + return Err(ProgramError::Custom(1)); // todo + } + + // Deserialize the program state account. + let state_account = &remaining_accounts[0]; + let mut state: #state_ty = { + let data = state_account.try_borrow_data()?; + let mut sliced: &[u8] = &data; + anchor_lang::AccountDeserialize::try_deserialize(&mut sliced)? + }; + + remaining_accounts = &remaining_accounts[1..]; + + // Deserialize the program's execution context. + let mut accounts = #anchor_ident::try_accounts( + program_id, + &mut remaining_accounts, + )?; + let ctx = Context::new(program_id, &mut accounts, remaining_accounts); + + // Execute user defined function. + state.#rpc_name( + ctx, + #(#rpc_arg_names),* + )?; + + // Serialize the state and save it to storage. + accounts.exit(program_id)?; + let mut data = state_account.try_borrow_mut_data()?; + let dst: &mut [u8] = &mut data; + let mut cursor = std::io::Cursor::new(dst); + state.try_serialize(&mut cursor)?; + + Ok(()) + } + } + } else { + let state_name: proc_macro2::TokenStream = state.name.parse().unwrap(); + quote! { + #[inline(never)] + pub fn #private_rpc_name( + program_id: &Pubkey, + accounts: &[AccountInfo], + #(#rpc_params),* + ) -> ProgramResult { + let mut remaining_accounts: &[AccountInfo] = accounts; + let mut accounts = #anchor_ident::try_accounts( + program_id, + &mut remaining_accounts, + )?; + #state_name::#rpc_name( + Context::new(program_id, &mut accounts, remaining_accounts), + #(#rpc_arg_names),* + )?; + accounts.exit(program_id) + } + } + } + }) + .collect::>() + }) + .collect(), + }; let non_inlined_handlers: Vec = program .rpcs .iter() @@ -451,6 +607,7 @@ pub fn generate_non_inlined_handlers(program: &Program) -> proc_macro2::TokenStr #non_inlined_idl #non_inlined_ctor #(#non_inlined_state_handlers)* + #(#non_inlined_state_trait_handlers)* #(#non_inlined_handlers)* } } @@ -479,7 +636,14 @@ pub fn generate_ctor_typed_variant_with_semi(program: &Program) -> proc_macro2:: match &program.state { None => quote! {}, Some(state) => { - let ctor_args = generate_ctor_typed_args(state); + let ctor_args: Vec = generate_ctor_typed_args(state) + .iter() + .map(|arg| { + format!("pub {}", parser::tts_to_string(&arg)) + .parse() + .unwrap() + }) + .collect(); if ctor_args.len() == 0 { quote! { #[derive(AnchorSerialize, AnchorDeserialize)] @@ -490,7 +654,7 @@ pub fn generate_ctor_typed_variant_with_semi(program: &Program) -> proc_macro2:: #[derive(AnchorSerialize, AnchorDeserialize)] pub struct __Ctor { #(#ctor_args),* - }; + } } } } @@ -821,7 +985,7 @@ fn generate_cpi(program: &Program) -> proc_macro2::TokenStream { // Rust doesn't have method overloading so no need to use the arguments. // However, we do namespace methods in the preeimage so that we can use // different traits with the same method name. -fn sighash(namespace: &str, name: &str) -> [u8; 8] { +pub fn sighash(namespace: &str, name: &str) -> [u8; 8] { let preimage = format!("{}::{}", namespace, name); let mut sighash = [0u8; 8]; diff --git a/lang/syn/src/lib.rs b/lang/syn/src/lib.rs index 6c76706e3b..33f0d989ed 100644 --- a/lang/syn/src/lib.rs +++ b/lang/syn/src/lib.rs @@ -32,6 +32,7 @@ pub struct State { pub strct: syn::ItemStruct, pub impl_block: syn::ItemImpl, pub methods: Vec, + pub interfaces: Vec, pub ctor: syn::ImplItemMethod, pub ctor_anchor: syn::Ident, // TODO: consolidate this with ctor above. } @@ -42,6 +43,14 @@ pub struct StateRpc { pub ident: syn::Ident, pub args: Vec, pub anchor_ident: syn::Ident, + // True if there exists a &self on the method. + pub has_receiver: bool, +} + +#[derive(Debug)] +pub struct StateInterface { + pub trait_name: String, + pub methods: Vec, } #[derive(Debug)] diff --git a/lang/syn/src/parser/program.rs b/lang/syn/src/parser/program.rs index 4cf3d53658..4c92fca407 100644 --- a/lang/syn/src/parser/program.rs +++ b/lang/syn/src/parser/program.rs @@ -1,5 +1,5 @@ use crate::parser; -use crate::{Program, Rpc, RpcArg, State, StateRpc}; +use crate::{Program, Rpc, RpcArg, State, StateInterface, StateRpc}; pub fn parse(program_mod: syn::ItemMod) -> Program { let mod_ident = &program_mod.ident; @@ -28,12 +28,15 @@ pub fn parse(program_mod: syn::ItemMod) -> Program { .next(); let impl_block: Option<&syn::ItemImpl> = strct.map(|strct| { - let item_impl = mod_content + let item_impls = mod_content .iter() .filter_map(|item| match item { syn::Item::Impl(item_impl) => { let impl_ty_str = parser::tts_to_string(&item_impl.self_ty); let strct_name = strct.ident.to_string(); + if item_impl.trait_.is_some() { + return None; + } if strct_name != impl_ty_str { return None; } @@ -41,9 +44,39 @@ pub fn parse(program_mod: syn::ItemMod) -> Program { } _ => None, }) - .next() - .expect("Must provide an implementation"); - item_impl + .collect::>(); + item_impls[0] + }); + + // All program interface implementations. + let trait_impls: Option> = strct.map(|_strct| { + mod_content + .iter() + .filter_map(|item| match item { + syn::Item::Impl(item_impl) => { + let trait_name = match &item_impl.trait_ { + None => return None, + Some((_, path, _)) => path + .segments + .iter() + .next() + .expect("Must have one segmeent in a path") + .ident + .clone() + .to_string(), + }; + if item_impl.trait_.is_none() { + return None; + } + let methods = parse_state_trait_methods(item_impl); + Some(StateInterface { + trait_name, + methods, + }) + } + _ => None, + }) + .collect::>() }); strct.map(|strct| { @@ -112,6 +145,7 @@ pub fn parse(program_mod: syn::ItemMod) -> Program { ident: m.sig.ident.clone(), args, anchor_ident, + has_receiver: true, }) } }, @@ -122,6 +156,7 @@ pub fn parse(program_mod: syn::ItemMod) -> Program { State { name: strct.ident.to_string(), strct: strct.clone(), + interfaces: trait_impls.expect("Some if state exists"), impl_block, ctor, ctor_anchor, @@ -206,3 +241,52 @@ fn extract_ident(path_ty: &syn::PatType) -> &proc_macro2::Ident { }; &path.segments[0].ident } + +fn parse_state_trait_methods(item_impl: &syn::ItemImpl) -> Vec { + item_impl + .items + .iter() + .filter_map(|item: &syn::ImplItem| match item { + syn::ImplItem::Method(m) => match m.sig.inputs.first() { + None => None, + Some(_arg) => { + let mut has_receiver = false; + let mut args = m + .sig + .inputs + .iter() + .filter_map(|arg| match arg { + syn::FnArg::Receiver(_) => { + has_receiver = true; + None + } + syn::FnArg::Typed(arg) => Some(arg), + }) + .map(|raw_arg| { + let ident = match &*raw_arg.pat { + syn::Pat::Ident(ident) => &ident.ident, + _ => panic!("invalid syntax"), + }; + RpcArg { + name: ident.clone(), + raw_arg: raw_arg.clone(), + } + }) + .collect::>(); + // Remove the Anchor accounts argument + let anchor = args.remove(0); + let anchor_ident = extract_ident(&anchor.raw_arg).clone(); + + Some(StateRpc { + raw_method: m.clone(), + ident: m.sig.ident.clone(), + args, + anchor_ident, + has_receiver, + }) + } + }, + _ => None, + }) + .collect() +} diff --git a/ts/package.json b/ts/package.json index 1c377a5914..c140adb5c5 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@project-serum/anchor", - "version": "0.1.0", + "version": "0.2.0-beta.1", "description": "Anchor client", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -27,11 +27,13 @@ "@solana/web3.js": "^0.90.4", "@types/bn.js": "^4.11.6", "@types/bs58": "^4.0.1", + "@types/crypto-hash": "^1.1.2", "@types/pako": "^1.0.1", "bn.js": "^5.1.2", "bs58": "^4.0.1", "buffer-layout": "^1.2.0", "camelcase": "^5.3.1", + "crypto-hash": "^1.3.0", "eventemitter3": "^4.0.7", "find": "^0.3.0", "js-sha256": "^0.9.0", diff --git a/ts/src/coder.ts b/ts/src/coder.ts index b562d30d27..385b91921b 100644 --- a/ts/src/coder.ts +++ b/ts/src/coder.ts @@ -356,7 +356,7 @@ export async function stateDiscriminator(name: string): Promise { // Returns the size of the type in bytes. For variable length types, just return // 1. Users should override this value in such cases. -export function typeSize(idl: Idl, ty: IdlType): number { +function typeSize(idl: Idl, ty: IdlType): number { switch (ty) { case "bool": return 1; @@ -386,7 +386,7 @@ export function typeSize(idl: Idl, ty: IdlType): number { // @ts-ignore if (ty.option !== undefined) { // @ts-ignore - return 1 + typeSize(ty.option); + return 1 + typeSize(idl, ty.option); } // @ts-ignore if (ty.defined !== undefined) { diff --git a/ts/yarn.lock b/ts/yarn.lock index 5025bbb9ff..ea7ce29d60 100644 --- a/ts/yarn.lock +++ b/ts/yarn.lock @@ -753,6 +753,13 @@ dependencies: "@types/node" "*" +"@types/crypto-hash@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@types/crypto-hash/-/crypto-hash-1.1.2.tgz#5a993deb0e6ba7c42f86eaa65d9bf563378f4569" + integrity sha512-sOmi+4Go2XKodLV4+lfP+5QMQ+6ZYqRJhK8D/n6xsxIUvlerEulmU9S4Lo02pXCH3qPBeJXEy+g8ZERktDJLSg== + dependencies: + crypto-hash "*" + "@types/express-serve-static-core@^4.17.9": version "4.17.18" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" @@ -1687,7 +1694,7 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -crypto-hash@^1.2.2: +crypto-hash@*, crypto-hash@^1.2.2, crypto-hash@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/crypto-hash/-/crypto-hash-1.3.0.tgz#b402cb08f4529e9f4f09346c3e275942f845e247" integrity sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== From a6cc210595b84c62ff9d4746009be1e3f1495fba Mon Sep 17 00:00:00 2001 From: Armani Ferrante Date: Sun, 7 Feb 2021 23:45:26 +0800 Subject: [PATCH 2/2] Lockup realization trait --- examples/lockup/programs/lockup/src/lib.rs | 67 +++++++++++++- examples/lockup/programs/registry/src/lib.rs | 64 +++++++++++-- examples/lockup/tests/lockup.js | 95 ++++++++++++++++++-- 3 files changed, 211 insertions(+), 15 deletions(-) diff --git a/examples/lockup/programs/lockup/src/lib.rs b/examples/lockup/programs/lockup/src/lib.rs index 4290b1b7fd..f59c8058f8 100644 --- a/examples/lockup/programs/lockup/src/lib.rs +++ b/examples/lockup/programs/lockup/src/lib.rs @@ -74,6 +74,7 @@ pub mod lockup { period_count: u64, deposit_amount: u64, nonce: u8, + realizor: Option, ) -> Result<()> { if end_ts <= ctx.accounts.clock.unix_timestamp { return Err(ErrorCode::InvalidTimestamp.into()); @@ -100,12 +101,14 @@ pub mod lockup { vesting.whitelist_owned = 0; vesting.grantor = *ctx.accounts.depositor_authority.key; vesting.nonce = nonce; + vesting.realizor = realizor; token::transfer(ctx.accounts.into(), deposit_amount)?; Ok(()) } + #[access_control(is_realized(&ctx))] pub fn withdraw(ctx: Context, amount: u64) -> Result<()> { // Has the given amount vested? if amount @@ -187,7 +190,7 @@ pub mod lockup { Ok(()) } - // Convenience function for UI's to calculate the withdrawalable amount. + // Convenience function for UI's to calculate the withdrawable amount. pub fn available_for_withdrawal(ctx: Context) -> Result<()> { let available = calculator::available_for_withdrawal( &ctx.accounts.vesting, @@ -242,6 +245,8 @@ impl<'info> CreateVesting<'info> { } } +// All accounts not included here, i.e., the "remaining accounts" should be +// ordered according to the realization interface. #[derive(Accounts)] pub struct Withdraw<'info> { // Vesting. @@ -327,6 +332,29 @@ pub struct Vesting { pub whitelist_owned: u64, /// Signer nonce. pub nonce: u8, + /// The program that determines when the locked account is **realized**. + /// In addition to the lockup schedule, the program provides the ability + /// for applications to determine when locked tokens are considered earned. + /// For example, when earning locked tokens via the staking program, one + /// cannot receive the tokens until unstaking. As a result, if one never + /// unstakes, one would never actually receive the locked tokens. + pub realizor: Option, +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct Realizor { + /// Program to invoke to check a realization condition. This program must + /// implement the `RealizeLock` trait. + pub program: Pubkey, + /// Address of an arbitrary piece of metadata interpretable by the realizor + /// program. For example, when a vesting account is allocated, the program + /// can define its realization condition as a function of some account + /// state. The metadata is the address of that account. + /// + /// In the case of staking, the metadata is a `Member` account address. When + /// the realization condition is checked, the staking program will check the + /// `Member` account defined by the `metadata` has no staked tokens. + pub metadata: Pubkey, } #[derive(AnchorSerialize, AnchorDeserialize, PartialEq, Default, Copy, Clone)] @@ -366,6 +394,12 @@ pub enum ErrorCode { WhitelistEntryNotFound, #[msg("You do not have sufficient permissions to perform this action.")] Unauthorized, + #[msg("You are unable to realize projected rewards until unstaking.")] + UnableToWithdrawWhileStaked, + #[msg("The given lock realizor doesn't match the vesting account.")] + InvalidLockRealizor, + #[msg("You have not realized this vesting account.")] + UnrealizedVesting, } impl<'a, 'b, 'c, 'info> From<&mut CreateVesting<'info>> @@ -456,3 +490,34 @@ fn whitelist_auth(lockup: &Lockup, ctx: &Context) -> Result<()> { } Ok(()) } + +// Returns Ok if the locked vesting account has been "realized". Realization +// is application dependent. For example, in the case of staking, one must first +// unstake before being able to earn locked tokens. +fn is_realized<'info>(ctx: &Context) -> Result<()> { + if let Some(realizor) = &ctx.accounts.vesting.realizor { + let cpi_program = { + let p = ctx.remaining_accounts[0].clone(); + if p.key != &realizor.program { + return Err(ErrorCode::InvalidLockRealizor.into()); + } + p + }; + let cpi_accounts = ctx.remaining_accounts.to_vec()[1..].to_vec(); + let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); + let vesting = (*ctx.accounts.vesting).clone(); + realize_lock::is_realized(cpi_ctx, vesting).map_err(|_| ErrorCode::UnrealizedVesting)?; + } + Ok(()) +} + +/// RealizeLock defines the interface an external program must implement if +/// they want to define a "realization condition" on a locked vesting account. +/// This condition must be satisfied *even if a vesting schedule has +/// completed*. Otherwise the user can never earn the locked funds. For example, +/// in the case of the staking program, one cannot received a locked reward +/// until one has completely unstaked. +#[interface] +pub trait RealizeLock<'info, T: Accounts<'info>> { + fn is_realized(ctx: Context, v: Vesting) -> ProgramResult; +} diff --git a/examples/lockup/programs/registry/src/lib.rs b/examples/lockup/programs/registry/src/lib.rs index 6b26bf0f9b..83f4a4ba66 100644 --- a/examples/lockup/programs/registry/src/lib.rs +++ b/examples/lockup/programs/registry/src/lib.rs @@ -6,7 +6,7 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::program_option::COption; use anchor_spl::token::{self, Mint, TokenAccount, Transfer}; -use lockup::{CreateVesting, Vesting}; +use lockup::{CreateVesting, RealizeLock, Realizor, Vesting}; use std::convert::Into; #[program] @@ -26,6 +26,23 @@ mod registry { } } + impl<'info> RealizeLock<'info, IsRealized<'info>> for Registry { + fn is_realized(ctx: Context, v: Vesting) -> ProgramResult { + if let Some(realizor) = &v.realizor { + if &realizor.metadata != ctx.accounts.member.to_account_info().key { + return Err(ErrorCode::InvalidRealizorMetadata.into()); + } + assert!(ctx.accounts.member.beneficiary == v.beneficiary); + let total_staked = + ctx.accounts.member_spt.amount + ctx.accounts.member_spt_locked.amount; + if total_staked != 0 { + return Err(ErrorCode::UnrealizedReward.into()); + } + } + Ok(()) + } + } + #[access_control(Initialize::accounts(&ctx, nonce))] pub fn initialize( ctx: Context, @@ -435,14 +452,27 @@ mod registry { .unwrap(); assert!(reward_amount > 0); - // Lockup program requires the timestamp to be >= clock's timestamp. - // So update if the time has already passed. 60 seconds is arbitrary. - let end_ts = match end_ts > ctx.accounts.cmn.clock.unix_timestamp + 60 { - true => end_ts, - false => ctx.accounts.cmn.clock.unix_timestamp + 60, + // The lockup program requires the timestamp to be >= clock's timestamp. + // So update if the time has already passed. + // + // If the reward is within `period_count` seconds of fully vesting, then + // we bump the `end_ts` because, otherwise, the vesting account would + // fail to be created. Vesting must have no more frequently than the + // smallest unit of time, once per second, expressed as + // `period_count <= end_ts - start_ts`. + let end_ts = match end_ts < ctx.accounts.cmn.clock.unix_timestamp + period_count as i64 { + true => ctx.accounts.cmn.clock.unix_timestamp + period_count as i64, + false => end_ts, }; - // Create lockup account for the member's beneficiary. + // Specify the vesting account's realizor, so that unlocks can only + // execute once completely unstaked. + let realizor = Some(Realizor { + program: *ctx.program_id, + metadata: *ctx.accounts.cmn.member.to_account_info().key, + }); + + // CPI: Create lockup account for the member's beneficiary. let seeds = &[ ctx.accounts.cmn.registrar.to_account_info().key.as_ref(), ctx.accounts.cmn.vendor.to_account_info().key.as_ref(), @@ -461,9 +491,10 @@ mod registry { period_count, reward_amount, nonce, + realizor, )?; - // Update the member account. + // Make sure this reward can't be processed more than once. let member = &mut ctx.accounts.cmn.member; member.rewards_cursor = ctx.accounts.cmn.vendor.reward_event_q_cursor + 1; @@ -609,6 +640,17 @@ pub struct Ctor<'info> { lockup_program: AccountInfo<'info>, } +#[derive(Accounts)] +pub struct IsRealized<'info> { + #[account( + "&member.balances.spt == member_spt.to_account_info().key", + "&member.balances_locked.spt == member_spt_locked.to_account_info().key" + )] + member: ProgramAccount<'info, Member>, + member_spt: CpiAccount<'info, TokenAccount>, + member_spt_locked: CpiAccount<'info, TokenAccount>, +} + #[derive(Accounts)] pub struct UpdateMember<'info> { #[account(mut, has_one = beneficiary)] @@ -1168,6 +1210,12 @@ pub enum ErrorCode { ExpectedUnlockedVendor, #[msg("Locked deposit from an invalid deposit authority.")] InvalidVestingSigner, + #[msg("Locked rewards cannot be realized until one unstaked all tokens.")] + UnrealizedReward, + #[msg("The beneficiary doesn't match.")] + InvalidBeneficiary, + #[msg("The given member account does not match the realizor metadata.")] + InvalidRealizorMetadata, } impl<'a, 'b, 'c, 'info> From<&mut Deposit<'info>> diff --git a/examples/lockup/tests/lockup.js b/examples/lockup/tests/lockup.js index 9edd88732c..86bf5db69a 100644 --- a/examples/lockup/tests/lockup.js +++ b/examples/lockup/tests/lockup.js @@ -159,6 +159,7 @@ describe("Lockup and Registry", () => { periodCount, depositAmount, nonce, + null, // Lock realizor is None. { accounts: { vesting: vesting.publicKey, @@ -194,6 +195,7 @@ describe("Lockup and Registry", () => { assert.ok(vestingAccount.whitelistOwned.eq(new anchor.BN(0))); assert.equal(vestingAccount.nonce, nonce); assert.ok(endTs.gt(vestingAccount.startTs)); + assert.ok(vestingAccount.realizor === null); }); it("Fails to withdraw from a vesting account before vesting", async () => { @@ -580,8 +582,8 @@ describe("Lockup and Registry", () => { it("Drops a locked reward", async () => { lockedRewardKind = { locked: { - endTs: new anchor.BN(Date.now() / 1000 + 70), - periodCount: new anchor.BN(10), + endTs: new anchor.BN(Date.now() / 1000 + 5), + periodCount: new anchor.BN(3), }, }; lockedRewardAmount = new anchor.BN(200); @@ -658,16 +660,21 @@ describe("Lockup and Registry", () => { assert.ok(e.locked === true); }); - it("Collects a locked reward", async () => { - const vendoredVesting = new anchor.web3.Account(); - const vendoredVestingVault = new anchor.web3.Account(); + let vendoredVesting = null; + let vendoredVestingVault = null; + let vendoredVestingSigner = null; + + it("Claims a locked reward", async () => { + vendoredVesting = new anchor.web3.Account(); + vendoredVestingVault = new anchor.web3.Account(); let [ - vendoredVestingSigner, + _vendoredVestingSigner, nonce, ] = await anchor.web3.PublicKey.findProgramAddress( [vendoredVesting.publicKey.toBuffer()], lockup.programId ); + vendoredVestingSigner = _vendoredVestingSigner; const remainingAccounts = lockup.instruction.createVesting .accounts({ vesting: vendoredVesting.publicKey, @@ -731,6 +738,51 @@ describe("Lockup and Registry", () => { lockupAccount.periodCount.eq(lockedRewardKind.locked.periodCount) ); assert.ok(lockupAccount.whitelistOwned.eq(new anchor.BN(0))); + assert.ok(lockupAccount.realizor.program.equals(registry.programId)); + assert.ok(lockupAccount.realizor.metadata.equals(member.publicKey)); + }); + + it("Waits for the lockup period to pass", async () => { + await serumCmn.sleep(10 * 1000); + }); + + it("Should fail to unlock an unrealized lockup reward", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + await assert.rejects( + async () => { + const withdrawAmount = new anchor.BN(10); + await lockup.rpc.withdraw(withdrawAmount, { + accounts: { + vesting: vendoredVesting.publicKey, + beneficiary: provider.wallet.publicKey, + token, + vault: vendoredVestingVault.publicKey, + vestingSigner: vendoredVestingSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + // TODO: trait methods generated on the client. Until then, we need to manually + // specify the account metas here. + remainingAccounts: [ + { pubkey: registry.programId, isWritable: false, isSigner: false }, + { pubkey: member.publicKey, isWritable: false, isSigner: false }, + { pubkey: balances.spt, isWritable: false, isSigner: false }, + { pubkey: balancesLocked.spt, isWritable: false, isSigner: false }, + ], + }); + }, + (err) => { + // Solana doesn't propagate errors across CPI. So we receive the registry's error code, + // not the lockup's. + const errorCode = "custom program error: 0x78"; + assert.ok(err.toString().split(errorCode).length === 2); + return true; + } + ); }); const pendingWithdrawal = new anchor.web3.Account(); @@ -857,4 +909,35 @@ describe("Lockup and Registry", () => { const tokenAccount = await serumCmn.getTokenAccount(provider, token); assert.ok(tokenAccount.amount.eq(withdrawAmount)); }); + + it("Should succesfully unlock a locked reward after unstaking", async () => { + const token = await serumCmn.createTokenAccount( + provider, + mint, + provider.wallet.publicKey + ); + + const withdrawAmount = new anchor.BN(7); + await lockup.rpc.withdraw(withdrawAmount, { + accounts: { + vesting: vendoredVesting.publicKey, + beneficiary: provider.wallet.publicKey, + token, + vault: vendoredVestingVault.publicKey, + vestingSigner: vendoredVestingSigner, + tokenProgram: TokenInstructions.TOKEN_PROGRAM_ID, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }, + // TODO: trait methods generated on the client. Until then, we need to manually + // specify the account metas here. + remainingAccounts: [ + { pubkey: registry.programId, isWritable: false, isSigner: false }, + { pubkey: member.publicKey, isWritable: false, isSigner: false }, + { pubkey: balances.spt, isWritable: false, isSigner: false }, + { pubkey: balancesLocked.spt, isWritable: false, isSigner: false }, + ], + }); + const tokenAccount = await serumCmn.getTokenAccount(provider, token); + assert.ok(tokenAccount.amount.eq(withdrawAmount)); + }); });