lang, ts: automatic client side pda derivation (#1331)

This commit is contained in:
Armani Ferrante 2022-01-24 14:44:24 -05:00 committed by GitHub
parent 1a2fd38451
commit d8d720067d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1027 additions and 78 deletions

View File

@ -272,6 +272,8 @@ jobs:
path: tests/ido-pool
- cmd: cd tests/cfo && anchor run test-with-build
path: tests/cfo
- cmd: cd tests/auction-house && yarn && anchor test
path: tests/auction-house
steps:
- uses: actions/checkout@v2
- uses: ./.github/actions/setup/

3
.gitmodules vendored
View File

@ -13,3 +13,6 @@
[submodule "examples/permissioned-markets/deps/serum-dex"]
path = tests/permissioned-markets/deps/serum-dex
url = https://github.com/project-serum/serum-dex
[submodule "tests/auction-house"]
path = tests/auction-house
url = https://github.com/armaniferrante/auction-house

View File

@ -162,7 +162,11 @@ impl WithPath<Config> {
let cargo = Manifest::from_path(&path.join("Cargo.toml"))?;
let lib_name = cargo.lib_name()?;
let version = cargo.version();
let idl = anchor_syn::idl::file::parse(path.join("src/lib.rs"), version)?;
let idl = anchor_syn::idl::file::parse(
path.join("src/lib.rs"),
version,
self.features.seeds,
)?;
r.push(Program {
lib_name,
path,
@ -243,6 +247,7 @@ impl<T> std::ops::DerefMut for WithPath<T> {
pub struct Config {
pub anchor_version: Option<String>,
pub solana_version: Option<String>,
pub features: FeaturesConfig,
pub registry: RegistryConfig,
pub provider: ProviderConfig,
pub programs: ProgramsConfig,
@ -251,6 +256,11 @@ pub struct Config {
pub test: Option<Test>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct FeaturesConfig {
pub seeds: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RegistryConfig {
pub url: String,
@ -362,6 +372,7 @@ impl Config {
struct _Config {
anchor_version: Option<String>,
solana_version: Option<String>,
features: Option<FeaturesConfig>,
programs: Option<BTreeMap<String, BTreeMap<String, serde_json::Value>>>,
registry: Option<RegistryConfig>,
provider: Provider,
@ -389,6 +400,7 @@ impl ToString for Config {
let cfg = _Config {
anchor_version: self.anchor_version.clone(),
solana_version: self.solana_version.clone(),
features: Some(self.features.clone()),
registry: Some(self.registry.clone()),
provider: Provider {
cluster: format!("{}", self.provider.cluster),
@ -417,6 +429,7 @@ impl FromStr for Config {
Ok(Config {
anchor_version: cfg.anchor_version,
solana_version: cfg.solana_version,
features: cfg.features.unwrap_or_default(),
registry: cfg.registry.unwrap_or_default(),
provider: ProviderConfig {
cluster: cfg.provider.cluster.parse()?,

View File

@ -861,7 +861,7 @@ fn build_cwd_verifiable(
Ok(_) => {
// Build the idl.
println!("Extracting the IDL");
if let Ok(Some(idl)) = extract_idl("src/lib.rs") {
if let Ok(Some(idl)) = extract_idl(cfg, "src/lib.rs") {
// Write out the JSON file.
println!("Writing the IDL file");
let out_file = workspace_dir.join(format!("target/idl/{}.json", idl.name));
@ -1135,7 +1135,7 @@ fn _build_cwd(
}
// Always assume idl is located at src/lib.rs.
if let Some(idl) = extract_idl("src/lib.rs")? {
if let Some(idl) = extract_idl(cfg, "src/lib.rs")? {
// JSON out path.
let out = match idl_out {
None => PathBuf::from(".").join(&idl.name).with_extension("json"),
@ -1219,7 +1219,7 @@ fn verify(
}
// Verify IDL (only if it's not a buffer account).
if let Some(local_idl) = extract_idl("src/lib.rs")? {
if let Some(local_idl) = extract_idl(&cfg, "src/lib.rs")? {
if bin_ver.state != BinVerificationState::Buffer {
let deployed_idl = fetch_idl(cfg_override, program_id)?;
if local_idl != deployed_idl {
@ -1383,12 +1383,12 @@ fn fetch_idl(cfg_override: &ConfigOverride, idl_addr: Pubkey) -> Result<Idl> {
serde_json::from_slice(&s[..]).map_err(Into::into)
}
fn extract_idl(file: &str) -> Result<Option<Idl>> {
fn extract_idl(cfg: &WithPath<Config>, file: &str) -> Result<Option<Idl>> {
let file = shellexpand::tilde(file);
let manifest_from_path = std::env::current_dir()?.join(PathBuf::from(&*file).parent().unwrap());
let cargo = Manifest::discover_from_path(manifest_from_path)?
.ok_or_else(|| anyhow!("Cargo.toml not found"))?;
anchor_syn::idl::file::parse(&*file, cargo.version())
anchor_syn::idl::file::parse(&*file, cargo.version(), cfg.features.seeds)
}
fn idl(cfg_override: &ConfigOverride, subcmd: IdlCommand) -> Result<()> {
@ -1415,7 +1415,7 @@ fn idl(cfg_override: &ConfigOverride, subcmd: IdlCommand) -> Result<()> {
} => idl_set_authority(cfg_override, program_id, address, new_authority),
IdlCommand::EraseAuthority { program_id } => idl_erase_authority(cfg_override, program_id),
IdlCommand::Authority { program_id } => idl_authority(cfg_override, program_id),
IdlCommand::Parse { file, out, out_ts } => idl_parse(file, out, out_ts),
IdlCommand::Parse { file, out, out_ts } => idl_parse(cfg_override, file, out, out_ts),
IdlCommand::Fetch { address, out } => idl_fetch(cfg_override, address, out),
}
}
@ -1674,8 +1674,14 @@ fn idl_write(cfg: &Config, program_id: &Pubkey, idl: &Idl, idl_address: Pubkey)
Ok(())
}
fn idl_parse(file: String, out: Option<String>, out_ts: Option<String>) -> Result<()> {
let idl = extract_idl(&file)?.ok_or_else(|| anyhow!("IDL not parsed"))?;
fn idl_parse(
cfg_override: &ConfigOverride,
file: String,
out: Option<String>,
out_ts: Option<String>,
) -> Result<()> {
let cfg = Config::discover(cfg_override)?.expect("Not in workspace.");
let idl = extract_idl(&cfg, &file)?.ok_or_else(|| anyhow!("IDL not parsed"))?;
let out = match out {
None => OutFile::Stdout,
Some(out) => OutFile::File(PathBuf::from(out)),

View File

@ -12,6 +12,7 @@ idl = []
hash = []
default = []
anchor-debug = []
seeds = []
[dependencies]
proc-macro2 = "1.0"

View File

@ -14,7 +14,11 @@ const DERIVE_NAME: &str = "Accounts";
const ERROR_CODE_OFFSET: u32 = 6000;
// Parse an entire interface file.
pub fn parse(filename: impl AsRef<Path>, version: String) -> Result<Option<Idl>> {
pub fn parse(
filename: impl AsRef<Path>,
version: String,
seeds_feature: bool,
) -> Result<Option<Idl>> {
let ctx = CrateContext::parse(filename)?;
let program_mod = match parse_program_mod(&ctx) {
@ -52,7 +56,8 @@ pub fn parse(filename: impl AsRef<Path>, version: String) -> Result<Option<Idl>>
.collect::<Vec<_>>();
let accounts_strct =
accs.get(&method.anchor_ident.to_string()).unwrap();
let accounts = idl_accounts(accounts_strct, &accs);
let accounts =
idl_accounts(&ctx, accounts_strct, &accs, seeds_feature);
IdlInstruction {
name,
accounts,
@ -91,7 +96,7 @@ pub fn parse(filename: impl AsRef<Path>, version: String) -> Result<Option<Idl>>
})
.collect();
let accounts_strct = accs.get(&anchor_ident.to_string()).unwrap();
let accounts = idl_accounts(accounts_strct, &accs);
let accounts = idl_accounts(&ctx, accounts_strct, &accs, seeds_feature);
IdlInstruction {
name,
accounts,
@ -159,7 +164,7 @@ pub fn parse(filename: impl AsRef<Path>, version: String) -> Result<Option<Idl>>
.collect::<Vec<_>>();
// todo: don't unwrap
let accounts_strct = accs.get(&ix.anchor_ident.to_string()).unwrap();
let accounts = idl_accounts(accounts_strct, &accs);
let accounts = idl_accounts(&ctx, accounts_strct, &accs, seeds_feature);
IdlInstruction {
name: ix.ident.to_string().to_mixed_case(),
accounts,
@ -494,8 +499,10 @@ fn to_idl_type(f: &syn::Field) -> IdlType {
}
fn idl_accounts(
ctx: &CrateContext,
accounts: &AccountsStruct,
global_accs: &HashMap<String, AccountsStruct>,
seeds_feature: bool,
) -> Vec<IdlAccountItem> {
accounts
.fields
@ -505,7 +512,7 @@ fn idl_accounts(
let accs_strct = global_accs
.get(&comp_f.symbol)
.expect("Could not resolve Accounts symbol");
let accounts = idl_accounts(accs_strct, global_accs);
let accounts = idl_accounts(ctx, accs_strct, global_accs, seeds_feature);
IdlAccountItem::IdlAccounts(IdlAccounts {
name: comp_f.ident.to_string().to_mixed_case(),
accounts,
@ -518,6 +525,7 @@ fn idl_accounts(
Ty::Signer => true,
_ => acc.constraints.is_signer(),
},
pda: pda::parse(ctx, accounts, acc, seeds_feature),
}),
})
.collect::<Vec<_>>()

View File

@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
pub mod file;
pub mod pda;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Idl {
@ -66,6 +67,52 @@ pub struct IdlAccount {
pub name: String,
pub is_mut: bool,
pub is_signer: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub pda: Option<IdlPda>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IdlPda {
pub seeds: Vec<IdlSeed>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub program_id: Option<IdlSeed>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum IdlSeed {
Const(IdlSeedConst),
Arg(IdlSeedArg),
Account(IdlSeedAccount),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IdlSeedAccount {
#[serde(rename = "type")]
pub ty: IdlType,
// account_ty points to the entry in the "accounts" section.
// Some only if the `Account<T>` type is used.
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IdlSeedArg {
#[serde(rename = "type")]
pub ty: IdlType,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IdlSeedConst {
#[serde(rename = "type")]
pub ty: IdlType,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -157,6 +204,7 @@ impl std::str::FromStr for IdlType {
}
}
s.retain(|c| !c.is_whitespace());
let r = match s.as_str() {
"bool" => IdlType::Bool,
"u8" => IdlType::U8,
@ -170,7 +218,7 @@ impl std::str::FromStr for IdlType {
"u128" => IdlType::U128,
"i128" => IdlType::I128,
"Vec<u8>" => IdlType::Bytes,
"String" => IdlType::String,
"String" | "&str" => IdlType::String,
"Pubkey" => IdlType::PublicKey,
_ => match s.to_string().strip_prefix("Option<") {
None => match s.to_string().strip_prefix("Vec<") {

322
lang/syn/src/idl/pda.rs Normal file
View File

@ -0,0 +1,322 @@
use crate::idl::*;
use crate::parser;
use crate::parser::context::CrateContext;
use crate::ConstraintSeedsGroup;
use crate::{AccountsStruct, Field};
use std::collections::HashMap;
use std::str::FromStr;
use syn::Expr;
// Parses a seeds constraint, extracting the IdlSeed types.
//
// Note: This implementation makes assumptions about the types that can be used
// (e.g., no program-defined function calls in seeds).
//
// This probably doesn't cover all cases. If you see a warning log, you
// can add a new case here. In the worst case, we miss a seed and
// the parser will treat the given seeds as empty and so clients will
// simply fail to automatically populate the PDA accounts.
//
// Seed Assumptions: Seeds must be of one of the following forms:
//
// - instruction argument.
// - account context field pubkey.
// - account data, where the account is defined in the current program.
// We make an exception for the SPL token program, since it is so common
// and sometimes convenient to use fields as a seed (e.g. Auction house
// program). In the case of nested structs/account data, all nested structs
// must be defined in the current program as well.
// - byte string literal (e.g. b"MY_SEED").
// - byte string literal constant (e.g. `pub const MY_SEED: [u8; 2] = *b"hi";`).
// - array constants.
//
pub fn parse(
ctx: &CrateContext,
accounts: &AccountsStruct,
acc: &Field,
seeds_feature: bool,
) -> Option<IdlPda> {
if !seeds_feature {
return None;
}
let pda_parser = PdaParser::new(ctx, accounts);
acc.constraints
.seeds
.as_ref()
.map(|s| pda_parser.parse(s))
.unwrap_or(None)
}
struct PdaParser<'a> {
ctx: &'a CrateContext,
// Accounts context.
accounts: &'a AccountsStruct,
// Maps var name to var type. These are the instruction arguments in a
// given accounts context.
ix_args: HashMap<String, String>,
// Constants available in the crate.
const_names: Vec<String>,
// All field names of the accounts in the accounts context.
account_field_names: Vec<String>,
}
impl<'a> PdaParser<'a> {
fn new(ctx: &'a CrateContext, accounts: &'a AccountsStruct) -> Self {
// All the available sources of seeds.
let ix_args = accounts.instruction_args().unwrap_or_default();
let const_names: Vec<String> = ctx.consts().map(|c| c.ident.to_string()).collect();
let account_field_names = accounts.field_names();
Self {
ctx,
accounts,
ix_args,
const_names,
account_field_names,
}
}
fn parse(&self, seeds_grp: &ConstraintSeedsGroup) -> Option<IdlPda> {
// Extract the idl seed types from the constraints.
let seeds = seeds_grp
.seeds
.iter()
.map(|s| self.parse_seed(s))
.collect::<Option<Vec<_>>>()?;
// Parse the program id from the constraints.
let program_id = seeds_grp
.program_seed
.as_ref()
.map(|pid| self.parse_seed(pid))
.unwrap_or_default();
// Done.
Some(IdlPda { seeds, program_id })
}
fn parse_seed(&self, seed: &Expr) -> Option<IdlSeed> {
match seed {
Expr::MethodCall(_) => {
let seed_path = parse_seed_path(seed)?;
if self.is_instruction(&seed_path) {
self.parse_instruction(&seed_path)
} else if self.is_const(&seed_path) {
self.parse_const(&seed_path)
} else if self.is_account(&seed_path) {
self.parse_account(&seed_path)
} else if self.is_str_literal(&seed_path) {
self.parse_str_literal(&seed_path)
} else {
println!("WARNING: unexpected seed category for var: {:?}", seed_path);
None
}
}
Expr::Reference(expr_reference) => self.parse_seed(&expr_reference.expr),
Expr::Index(_) => {
println!("WARNING: auto pda derivation not currently supported for slice literals");
None
}
// Unknown type. Please file an issue.
_ => {
println!("WARNING: unexpected seed: {:?}", seed);
None
}
}
}
fn parse_instruction(&self, seed_path: &SeedPath) -> Option<IdlSeed> {
let idl_ty = IdlType::from_str(self.ix_args.get(&seed_path.name()).unwrap()).ok()?;
Some(IdlSeed::Arg(IdlSeedArg {
ty: idl_ty,
path: seed_path.path(),
}))
}
fn parse_const(&self, seed_path: &SeedPath) -> Option<IdlSeed> {
// Pull in the constant value directly into the IDL.
assert!(seed_path.components().is_empty());
let const_item = self
.ctx
.consts()
.find(|c| c.ident == seed_path.name())
.unwrap();
let idl_ty = IdlType::from_str(&parser::tts_to_string(&const_item.ty)).ok()?;
let mut idl_ty_value = parser::tts_to_string(&const_item.expr);
if let IdlType::Array(_ty, _size) = &idl_ty {
// Convert str literal to array.
if idl_ty_value.contains("b\"") {
let components: Vec<&str> = idl_ty_value.split('b').collect();
assert!(components.len() == 2);
let mut str_lit = components[1].to_string();
str_lit.retain(|c| c != '"');
idl_ty_value = format!("{:?}", str_lit.as_bytes());
}
}
Some(IdlSeed::Const(IdlSeedConst {
ty: idl_ty,
value: serde_json::from_str(&idl_ty_value).unwrap(),
}))
}
fn parse_account(&self, seed_path: &SeedPath) -> Option<IdlSeed> {
// Get the anchor account field from the derive accounts struct.
let account_field = self
.accounts
.fields
.iter()
.find(|field| *field.ident() == seed_path.name())
.unwrap();
// Follow the path to find the seed type.
let ty = {
let mut path = seed_path.components();
match path.len() {
0 => IdlType::PublicKey,
1 => {
// Name of the account struct.
let account = account_field.ty_name()?;
if account == "TokenAccount" {
assert!(path.len() == 1);
match path[0].as_str() {
"mint" => IdlType::PublicKey,
"amount" => IdlType::U64,
"authority" => IdlType::PublicKey,
"delegated_amount" => IdlType::U64,
_ => {
println!("WARNING: token field isn't supported: {}", &path[0]);
return None;
}
}
} else {
// Get the rust representation of the field's struct.
let strct = self.ctx.structs().find(|s| s.ident == account).unwrap();
parse_field_path(self.ctx, strct, &mut path)
}
}
_ => panic!("invariant violation"),
}
};
Some(IdlSeed::Account(IdlSeedAccount {
ty,
account: account_field.ty_name(),
path: seed_path.path(),
}))
}
fn parse_str_literal(&self, seed_path: &SeedPath) -> Option<IdlSeed> {
let mut var_name = seed_path.name();
// Remove the byte `b` prefix if the string is of the form `b"seed".
if var_name.starts_with("b\"") {
var_name.remove(0);
}
let value_string: String = var_name.chars().filter(|c| *c != '"').collect();
Some(IdlSeed::Const(IdlSeedConst {
value: serde_json::Value::String(value_string),
ty: IdlType::String,
}))
}
fn is_instruction(&self, seed_path: &SeedPath) -> bool {
self.ix_args.contains_key(&seed_path.name())
}
fn is_const(&self, seed_path: &SeedPath) -> bool {
self.const_names.contains(&seed_path.name())
}
fn is_account(&self, seed_path: &SeedPath) -> bool {
self.account_field_names.contains(&seed_path.name())
}
fn is_str_literal(&self, seed_path: &SeedPath) -> bool {
seed_path.components().is_empty() && seed_path.name().contains('"')
}
}
// SeedPath represents the deconstructed syntax of a single pda seed,
// consisting of a variable name and a vec of all the sub fields accessed
// on that variable name. For example, if a seed is `my_field.my_data.as_ref()`,
// then the field name is `my_field` and the vec of sub fields is `[my_data]`.
#[derive(Debug)]
struct SeedPath(String, Vec<String>);
impl SeedPath {
fn name(&self) -> String {
self.0.clone()
}
// Full path to the data this seed represents.
fn path(&self) -> String {
match self.1.len() {
0 => self.0.clone(),
_ => format!("{}.{}", self.name(), self.components().join(".")),
}
}
// All path components for the subfields accessed on this seed.
fn components(&self) -> &[String] {
&self.1
}
}
// Extracts the seed path from a single seed expression.
fn parse_seed_path(seed: &Expr) -> Option<SeedPath> {
// Convert the seed into the raw string representation.
let seed_str = parser::tts_to_string(&seed);
// Break up the seed into each sub field component.
let mut components: Vec<&str> = seed_str.split(" . ").collect();
if components.len() <= 1 {
println!("WARNING: seeds are in an unexpected format: {:?}", seed);
return None;
}
// The name of the variable (or field).
let name = components.remove(0).to_string();
// The path to the seed (only if the `name` type is a struct).
let mut path = Vec::new();
while !components.is_empty() {
let c = components.remove(0);
if c.contains("()") {
break;
}
path.push(c.to_string());
}
if path.len() == 1 && (path[0] == "key" || path[0] == "key()") {
path = Vec::new();
}
Some(SeedPath(name, path))
}
fn parse_field_path(ctx: &CrateContext, strct: &syn::ItemStruct, path: &mut &[String]) -> IdlType {
let field_name = &path[0];
*path = &path[1..];
// Get the type name for the field.
let next_field = strct
.fields
.iter()
.find(|f| &f.ident.clone().unwrap().to_string() == field_name)
.unwrap();
let next_field_ty_str = parser::tts_to_string(&next_field.ty);
// The path is empty so this must be a primitive type.
if path.is_empty() {
return next_field_ty_str.parse().unwrap();
}
// Get the rust representation of hte field's struct.
let strct = ctx
.structs()
.find(|s| s.ident == next_field_ty_str)
.unwrap();
parse_field_path(ctx, strct, path)
}

View File

@ -5,6 +5,7 @@ use parser::program as program_parser;
use proc_macro2::{Span, TokenStream};
use quote::quote;
use quote::ToTokens;
use std::collections::HashMap;
use std::ops::Deref;
use syn::ext::IdentExt;
use syn::parse::{Error as ParseError, Parse, ParseStream, Result as ParseResult};
@ -145,6 +146,30 @@ impl AccountsStruct {
instruction_api,
}
}
// Return value maps instruction name to type.
// E.g. if we have `#[instruction(data: u64)]` then returns
// { "data": "u64"}.
pub fn instruction_args(&self) -> Option<HashMap<String, String>> {
self.instruction_api.as_ref().map(|instruction_api| {
instruction_api
.iter()
.map(|expr| {
let arg = parser::tts_to_string(&expr);
let components: Vec<&str> = arg.split(" : ").collect();
assert!(components.len() == 2);
(components[0].to_string(), components[1].to_string())
})
.collect()
})
}
pub fn field_names(&self) -> Vec<String> {
self.fields
.iter()
.map(|field| field.ident().to_string())
.collect()
}
}
#[allow(clippy::large_enum_variant)]
@ -161,6 +186,19 @@ impl AccountField {
AccountField::CompositeField(c_field) => &c_field.ident,
}
}
pub fn ty_name(&self) -> Option<String> {
match self {
AccountField::Field(field) => match &field.ty {
Ty::Account(account) => Some(parser::tts_to_string(&account.account_type_path)),
Ty::ProgramAccount(account) => {
Some(parser::tts_to_string(&account.account_type_path))
}
_ => None,
},
AccountField::CompositeField(field) => Some(field.symbol.clone()),
}
}
}
#[derive(Debug)]
@ -689,7 +727,7 @@ pub struct ConstraintSeedsGroup {
pub is_init: bool,
pub seeds: Punctuated<Expr, Token![,]>,
pub bump: Option<Expr>, // None => bump was given without a target.
pub program_seed: Option<Expr>, // None => use the current program's program_id
pub program_seed: Option<Expr>, // None => use the current program's program_id.
}
#[derive(Debug, Clone)]

View File

@ -1,2 +1,3 @@
**/target/types/*.ts
cfo/deps/
auction-house/deps/

1
tests/auction-house Submodule

@ -0,0 +1 @@
Subproject commit fea2d89c2b17ee39fcf0ebaadb0317b9e97206f4

View File

@ -18,3 +18,4 @@ default = []
anchor-lang = { path = "../../../../lang" }
anchor-spl = { path = "../../../../spl" }
misc2 = { path = "../misc2", features = ["cpi"] }
spl-associated-token-account = "=1.0.3"

View File

@ -1,5 +1,4 @@
const anchor = require("@project-serum/anchor");
const PublicKey = anchor.web3.PublicKey;
const assert = require("assert");
const {
ASSOCIATED_TOKEN_PROGRAM_ID,
@ -7,7 +6,12 @@ const {
Token,
} = require("@solana/spl-token");
const miscIdl = require("../target/idl/misc.json");
const { SystemProgram } = require("@solana/web3.js");
const {
SystemProgram,
Keypair,
PublicKey,
SYSVAR_RENT_PUBKEY,
} = require("@solana/web3.js");
const utf8 = anchor.utils.bytes.utf8;
describe("misc", () => {
@ -1221,14 +1225,14 @@ describe("misc", () => {
});
it("init_if_needed throws if associated token exists but has the wrong owner", async () => {
const mint = anchor.web3.Keypair.generate();
const mint = Keypair.generate();
await program.rpc.testInitMint({
accounts: {
mint: mint.publicKey,
payer: program.provider.wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
systemProgram: SystemProgram.programId,
tokenProgram: TOKEN_PROGRAM_ID,
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
rent: SYSVAR_RENT_PUBKEY,
},
signers: [mint],
});

View File

@ -20,6 +20,7 @@
"misc",
"multisig",
"permissioned-markets",
"pda-derivation",
"pyth",
"spl/token-proxy",
"swap",

View File

@ -0,0 +1,15 @@
[features]
seeds = true
[provider]
cluster = "localnet"
wallet = "~/.config/solana/id.json"
[programs.localnet]
pda_derivation = "Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS"
[workspace]
members = ["programs/pda-derivation"]
[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

View File

@ -0,0 +1,4 @@
[workspace]
members = [
"programs/*"
]

View File

@ -0,0 +1,22 @@
// Migrations are an early feature. Currently, they're nothing more than this
// single deploy script that's invoked from the CLI, injecting a provider
// configured from the workspace's Anchor.toml.
const anchor = require("@project-serum/anchor");
module.exports = async function (provider) {
// Configure client to use the provider.
anchor.setProvider(provider);
// Add your deploy script here.
async function deployAsync(exampleString: string): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
console.log(exampleString);
resolve();
}, 2000);
});
}
await deployAsync("Typescript migration example complete.");
};

View File

@ -0,0 +1,19 @@
{
"name": "pda-derivation",
"version": "0.20.1",
"license": "(MIT OR Apache-2.0)",
"homepage": "https://github.com/project-serum/anchor#readme",
"bugs": {
"url": "https://github.com/project-serum/anchor/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/project-serum/anchor.git"
},
"engines": {
"node": ">=11"
},
"scripts": {
"test": "anchor test"
}
}

View File

@ -0,0 +1,18 @@
[package]
name = "pda-derivation"
version = "0.1.0"
description = "Created with Anchor"
edition = "2018"
[lib]
crate-type = ["cdylib", "lib"]
name = "pda_derivation"
[features]
no-entrypoint = []
no-idl = []
cpi = ["no-entrypoint"]
default = []
[dependencies]
anchor-lang = { path = "../../../../lang" }

View File

@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []

View File

@ -0,0 +1,83 @@
//! The typescript example serves to show how one would setup an Anchor
//! workspace with TypeScript tests and migrations.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
pub const MY_SEED: [u8; 2] = *b"hi";
pub const MY_SEED_STR: &str = "hi";
pub const MY_SEED_U8: u8 = 1;
pub const MY_SEED_U32: u32 = 2;
pub const MY_SEED_U64: u64 = 3;
#[program]
pub mod pda_derivation {
use super::*;
pub fn init_base(ctx: Context<InitBase>, data: u64, data_key: Pubkey) -> ProgramResult {
let base = &mut ctx.accounts.base;
base.base_data = data;
base.base_data_key = data_key;
Ok(())
}
pub fn init_my_account(ctx: Context<InitMyAccount>, seed_a: u8) -> ProgramResult {
Ok(())
}
}
#[derive(Accounts)]
pub struct InitBase<'info> {
#[account(
init,
payer = payer,
space = 8+8+32,
)]
base: Account<'info, BaseAccount>,
#[account(mut)]
payer: Signer<'info>,
system_program: Program<'info, System>,
}
#[derive(Accounts)]
#[instruction(seed_a: u8)]
pub struct InitMyAccount<'info> {
base: Account<'info, BaseAccount>,
base2: AccountInfo<'info>,
#[account(
init,
payer = payer,
space = 8+8,
seeds = [
&seed_a.to_le_bytes(),
"another-seed".as_bytes(),
b"test".as_ref(),
base.key().as_ref(),
base2.key.as_ref(),
MY_SEED.as_ref(),
MY_SEED_STR.as_bytes(),
MY_SEED_U8.to_le_bytes().as_ref(),
&MY_SEED_U32.to_le_bytes(),
&MY_SEED_U64.to_le_bytes(),
base.base_data.to_le_bytes().as_ref(),
base.base_data_key.as_ref(),
],
bump,
)]
account: Account<'info, MyAccount>,
#[account(mut)]
payer: Signer<'info>,
system_program: Program<'info, System>,
}
#[account]
pub struct MyAccount {
data: u64,
}
#[account]
pub struct BaseAccount {
base_data: u64,
base_data_key: Pubkey,
}

View File

@ -0,0 +1,34 @@
import * as anchor from "@project-serum/anchor";
import BN from "bn.js";
import { Keypair } from "@solana/web3.js";
describe("typescript", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.Provider.env());
const program = anchor.workspace.PdaDerivation;
const base = Keypair.generate();
const dataKey = Keypair.generate();
const data = new BN(1);
const seedA = 4;
it("Inits the base account", async () => {
await program.methods
.initBase(data, dataKey.publicKey)
.accounts({
base: base.publicKey,
})
.signers([base])
.rpc();
});
it("Inits the derived accounts", async () => {
await program.methods
.initMyAccount(seedA)
.accounts({
base: base.publicKey,
base2: base.publicKey,
})
.rpc();
});
});

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"types": ["mocha", "chai"],
"typeRoots": ["./node_modules/@types"],
"lib": ["es2015"],
"module": "commonjs",
"target": "es6",
"esModuleInterop": true
}
}

View File

@ -2,6 +2,7 @@ import BN from "bn.js";
import * as BufferLayout from "buffer-layout";
import { Layout } from "buffer-layout";
import { PublicKey } from "@solana/web3.js";
import * as utils from "../../utils";
export function uint64(property?: string): Layout<u64 | null> {
return new WrappedLayout(
@ -74,30 +75,24 @@ export class COptionLayout<T> extends Layout<T | null> {
encode(src: T | null, b: Buffer, offset = 0): number {
if (src === null || src === undefined) {
return this.discriminator.encode(0, b, offset);
return this.layout.span + this.discriminator.encode(0, b, offset);
}
this.discriminator.encode(1, b, offset);
return this.layout.encode(src, b, offset + 4) + 4;
}
decode(b: Buffer, offset = 0): T | null {
const discriminator = b[offset];
const discriminator = this.discriminator.decode(b, offset);
if (discriminator === 0) {
return null;
} else if (discriminator === 1) {
return this.layout.decode(b, offset + 4);
}
throw new Error("Invalid option " + this.property);
throw new Error("Invalid coption " + this.property);
}
getSpan(b: Buffer, offset = 0): number {
const discriminator = b[offset];
if (discriminator === 0) {
return 1;
} else if (discriminator === 1) {
return this.layout.getSpan(b, offset + 4) + 4;
}
throw new Error("Invalid option " + this.property);
return this.layout.getSpan(b, offset + 4) + 4;
}
}

View File

@ -50,8 +50,16 @@ export type IdlAccount = {
name: string;
isMut: boolean;
isSigner: boolean;
pda?: IdlPda;
};
export type IdlPda = {
seeds: IdlSeed[];
programId?: IdlSeed;
};
export type IdlSeed = any; // TODO
// A nested/recursive version of IdlAccount.
export type IdlAccounts = {
name: string;

View File

@ -0,0 +1,254 @@
import camelCase from "camelcase";
import { PublicKey, SystemProgram, SYSVAR_RENT_PUBKEY } from "@solana/web3.js";
import { Idl, IdlSeed, IdlAccount } from "../idl.js";
import * as utf8 from "../utils/bytes/utf8.js";
import { TOKEN_PROGRAM_ID, ASSOCIATED_PROGRAM_ID } from "../utils/token.js";
import { AllInstructions } from "./namespace/types.js";
import Provider from "../provider.js";
import { AccountNamespace } from "./namespace/account.js";
import { coder } from "../spl/token";
// Populates a given accounts context with PDAs and common missing accounts.
export class AccountsResolver<IDL extends Idl, I extends AllInstructions<IDL>> {
private _accountStore: AccountStore<IDL>;
constructor(
private _args: Array<any>,
private _accounts: { [name: string]: PublicKey },
private _provider: Provider,
private _programId: PublicKey,
private _idlIx: AllInstructions<IDL>,
_accountNamespace: AccountNamespace<IDL>
) {
this._accountStore = new AccountStore(_provider, _accountNamespace);
}
// Note: We serially resolve PDAs one by one rather than doing them
// in parallel because there can be dependencies between
// addresses. That is, one PDA can be used as a seed in another.
//
// TODO: PDAs need to be resolved in topological order. For now, we
// require the developer to simply list the accounts in the
// correct order. But in future work, we should create the
// dependency graph and resolve automatically.
//
public async resolve() {
for (let k = 0; k < this._idlIx.accounts.length; k += 1) {
// Cast is ok because only a non-nested IdlAccount can have a seeds
// cosntraint.
const accountDesc = this._idlIx.accounts[k] as IdlAccount;
const accountDescName = camelCase(accountDesc.name);
// PDA derived from IDL seeds.
if (accountDesc.pda && accountDesc.pda.seeds.length > 0) {
if (this._accounts[accountDescName] === undefined) {
await this.autoPopulatePda(accountDesc);
continue;
}
}
// Signers default to the provider.
if (
accountDesc.isSigner &&
this._accounts[accountDescName] === undefined
) {
this._accounts[accountDescName] = this._provider.wallet.publicKey;
continue;
}
// Common accounts are auto populated with magic names by convention.
switch (accountDescName) {
case "systemProgram":
if (this._accounts[accountDescName] === undefined) {
this._accounts[accountDescName] = SystemProgram.programId;
}
case "rent":
if (this._accounts[accountDescName] === undefined) {
this._accounts[accountDescName] = SYSVAR_RENT_PUBKEY;
}
case "tokenProgram":
if (this._accounts[accountDescName] === undefined) {
this._accounts[accountDescName] = TOKEN_PROGRAM_ID;
}
case "associatedTokenProgram":
if (this._accounts[accountDescName] === undefined) {
this._accounts[accountDescName] = ASSOCIATED_PROGRAM_ID;
}
}
}
}
private async autoPopulatePda(accountDesc: IdlAccount) {
if (!accountDesc.pda || !accountDesc.pda.seeds)
throw new Error("Must have seeds");
const seeds: Buffer[] = await Promise.all(
accountDesc.pda.seeds.map((seedDesc: IdlSeed) => this.toBuffer(seedDesc))
);
const programId = await this.parseProgramId(accountDesc);
const [pubkey] = await PublicKey.findProgramAddress(seeds, programId);
this._accounts[camelCase(accountDesc.name)] = pubkey;
}
private async parseProgramId(accountDesc: IdlAccount): Promise<PublicKey> {
if (!accountDesc.pda?.programId) {
return this._programId;
}
switch (accountDesc.pda.programId.kind) {
case "const":
return new PublicKey(
this.toBufferConst(accountDesc.pda.programId.value)
);
case "arg":
return this.argValue(accountDesc.pda.programId);
case "account":
return await this.accountValue(accountDesc.pda.programId);
default:
throw new Error(
`Unexpected program seed kind: ${accountDesc.pda.programId.kind}`
);
}
}
private async toBuffer(seedDesc: IdlSeed): Promise<Buffer> {
switch (seedDesc.kind) {
case "const":
return this.toBufferConst(seedDesc);
case "arg":
return await this.toBufferArg(seedDesc);
case "account":
return await this.toBufferAccount(seedDesc);
default:
throw new Error(`Unexpected seed kind: ${seedDesc.kind}`);
}
}
private toBufferConst(seedDesc: IdlSeed): Buffer {
return this.toBufferValue(seedDesc.type, seedDesc.value);
}
private async toBufferArg(seedDesc: IdlSeed): Promise<Buffer> {
const argValue = this.argValue(seedDesc);
return this.toBufferValue(seedDesc.type, argValue);
}
private argValue(seedDesc: IdlSeed): any {
const seedArgName = camelCase(seedDesc.path.split(".")[0]);
const idlArgPosition = this._idlIx.args.findIndex(
(argDesc: any) => argDesc.name === seedArgName
);
if (idlArgPosition === -1) {
throw new Error(`Unable to find argument for seed: ${seedArgName}`);
}
return this._args[idlArgPosition];
}
private async toBufferAccount(seedDesc: IdlSeed): Promise<Buffer> {
const accountValue = await this.accountValue(seedDesc);
return this.toBufferValue(seedDesc.type, accountValue);
}
private async accountValue(seedDesc: IdlSeed): Promise<any> {
const pathComponents = seedDesc.path.split(".");
const fieldName = pathComponents[0];
const fieldPubkey = this._accounts[camelCase(fieldName)];
// The seed is a pubkey of the account.
if (pathComponents.length === 1) {
return fieldPubkey;
}
// The key is account data.
//
// Fetch and deserialize it.
const account = await this._accountStore.fetchAccount(
seedDesc.account,
fieldPubkey
);
// Dereference all fields in the path to get the field value
// used in the seed.
const fieldValue = this.parseAccountValue(account, pathComponents.slice(1));
return fieldValue;
}
private parseAccountValue<T = any>(account: T, path: Array<string>): any {
let accountField: any;
while (path.length > 0) {
accountField = account[camelCase(path[0])];
path = path.slice(1);
}
return accountField;
}
// Converts the given idl valaue into a Buffer. The values here must be
// primitives. E.g. no structs.
//
// TODO: add more types here as needed.
private toBufferValue(type: string | any, value: any): Buffer {
switch (type) {
case "u8":
return Buffer.from([value]);
case "u16":
let b = Buffer.alloc(2);
b.writeUInt16LE(value);
return b;
case "u32":
let buf = Buffer.alloc(4);
buf.writeUInt32LE(value);
return buf;
case "u64":
let bU64 = Buffer.alloc(8);
bU64.writeBigUInt64LE(BigInt(value));
return bU64;
case "string":
return Buffer.from(utf8.encode(value));
case "publicKey":
return value.toBuffer();
default:
if (type.array) {
return Buffer.from(value);
}
throw new Error(`Unexpected seed type: ${type}`);
}
}
}
// TODO: this should be configureable to avoid unnecessary requests.
export class AccountStore<IDL extends Idl> {
private _cache = new Map<string, any>();
// todo: don't use the progrma use the account namespace.
constructor(
private _provider: Provider,
private _accounts: AccountNamespace<IDL>
) {}
public async fetchAccount<T = any>(
name: string,
publicKey: PublicKey
): Promise<T> {
const address = publicKey.toString();
if (this._cache.get(address) === undefined) {
if (name === "TokenAccount") {
const accountInfo = await this._provider.connection.getAccountInfo(
publicKey
);
if (accountInfo === null) {
throw new Error(`invalid account info for ${address}`);
}
const data = coder().accounts.decode("Token", accountInfo.data);
this._cache.set(address, data);
} else {
const account = this._accounts[camelCase(name)].fetch(publicKey);
this._cache.set(address, account);
}
}
return this._cache.get(address);
}
}

View File

@ -49,6 +49,10 @@ export default class NamespaceFactory {
const idlErrors = parseIdlErrors(idl);
const account: AccountNamespace<IDL> = idl.accounts
? AccountFactory.build(idl, coder, programId, provider)
: ({} as AccountNamespace<IDL>);
const state = StateFactory.build(idl, coder, programId, provider);
idl.instructions.forEach(<I extends AllInstructions<IDL>>(idlIx: I) => {
@ -69,10 +73,14 @@ export default class NamespaceFactory {
idl
);
const methodItem = MethodsBuilderFactory.build(
provider,
programId,
idlIx,
ixItem,
txItem,
rpcItem,
simulateItem
simulateItem,
account
);
const name = camelCase(idlIx.name);
@ -84,10 +92,6 @@ export default class NamespaceFactory {
methods[name] = methodItem;
});
const account: AccountNamespace<IDL> = idl.accounts
? AccountFactory.build(idl, coder, programId, provider)
: ({} as AccountNamespace<IDL>);
return [
rpc as RpcNamespace<IDL>,
instruction as InstructionNamespace<IDL>,

View File

@ -7,46 +7,78 @@ import {
TransactionSignature,
PublicKey,
} from "@solana/web3.js";
import { SimulateResponse } from "./simulate";
import { SimulateResponse } from "./simulate.js";
import { TransactionFn } from "./transaction.js";
import { Idl } from "../../idl.js";
import {
AllInstructions,
InstructionContextFn,
MakeInstructionsNamespace,
} from "./types";
import { InstructionFn } from "./instruction";
import { RpcFn } from "./rpc";
import { SimulateFn } from "./simulate";
import { AllInstructions, MethodsFn, MakeMethodsNamespace } from "./types.js";
import { InstructionFn } from "./instruction.js";
import { RpcFn } from "./rpc.js";
import { SimulateFn } from "./simulate.js";
import Provider from "../../provider.js";
import { AccountNamespace } from "./account.js";
import { AccountsResolver } from "../accounts-resolver.js";
export type MethodsNamespace<
IDL extends Idl = Idl,
I extends AllInstructions<IDL> = AllInstructions<IDL>
> = MakeMethodsNamespace<IDL, I>;
export class MethodsBuilderFactory {
public static build<IDL extends Idl, I extends AllInstructions<IDL>>(
provider: Provider,
programId: PublicKey,
idlIx: AllInstructions<IDL>,
ixFn: InstructionFn<IDL>,
txFn: TransactionFn<IDL>,
rpcFn: RpcFn<IDL>,
simulateFn: SimulateFn<IDL>
): MethodFn {
const request: MethodFn<IDL, I> = (...args) => {
return new MethodsBuilder(args, ixFn, txFn, rpcFn, simulateFn);
simulateFn: SimulateFn<IDL>,
accountNamespace: AccountNamespace<IDL>
): MethodsFn<IDL, I, any> {
const request: MethodsFn<IDL, I, any> = (...args) => {
return new MethodsBuilder(
args,
ixFn,
txFn,
rpcFn,
simulateFn,
provider,
programId,
idlIx,
accountNamespace
);
};
return request;
}
}
export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
private _accounts: { [name: string]: PublicKey } = {};
readonly _accounts: { [name: string]: PublicKey } = {};
private _remainingAccounts: Array<AccountMeta> = [];
private _signers: Array<Signer> = [];
private _preInstructions: Array<TransactionInstruction> = [];
private _postInstructions: Array<TransactionInstruction> = [];
private _accountsResolver: AccountsResolver<IDL, I>;
constructor(
private _args: Array<any>,
private _ixFn: InstructionFn<IDL>,
private _txFn: TransactionFn<IDL>,
private _rpcFn: RpcFn<IDL>,
private _simulateFn: SimulateFn<IDL>
) {}
private _simulateFn: SimulateFn<IDL>,
_provider: Provider,
_programId: PublicKey,
_idlIx: AllInstructions<IDL>,
_accountNamespace: AccountNamespace<IDL>
) {
this._accountsResolver = new AccountsResolver(
_args,
this._accounts,
_provider,
_programId,
_idlIx,
_accountNamespace
);
}
// TODO: don't use any.
public accounts(accounts: any): MethodsBuilder<IDL, I> {
@ -54,6 +86,11 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
return this;
}
public signers(signers: Array<Signer>): MethodsBuilder<IDL, I> {
this._signers = this._signers.concat(signers);
return this;
}
public remainingAccounts(
accounts: Array<AccountMeta>
): MethodsBuilder<IDL, I> {
@ -76,7 +113,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
}
public async rpc(options: ConfirmOptions): Promise<TransactionSignature> {
await this.resolvePdas();
await this._accountsResolver.resolve();
// @ts-ignore
return this._rpcFn(...this._args, {
accounts: this._accounts,
@ -91,7 +128,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
public async simulate(
options: ConfirmOptions
): Promise<SimulateResponse<any, any>> {
await this.resolvePdas();
await this._accountsResolver.resolve();
// @ts-ignore
return this._simulateFn(...this._args, {
accounts: this._accounts,
@ -104,7 +141,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
}
public async instruction(): Promise<TransactionInstruction> {
await this.resolvePdas();
await this._accountsResolver.resolve();
// @ts-ignore
return this._ixFn(...this._args, {
accounts: this._accounts,
@ -116,7 +153,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
}
public async transaction(): Promise<Transaction> {
await this.resolvePdas();
await this._accountsResolver.resolve();
// @ts-ignore
return this._txFn(...this._args, {
accounts: this._accounts,
@ -126,18 +163,4 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
postInstructions: this._postInstructions,
});
}
private async resolvePdas() {
// TODO: resolve all PDAs and accounts not provided.
}
}
export type MethodsNamespace<
IDL extends Idl = Idl,
I extends AllInstructions<IDL> = AllInstructions<IDL>
> = MakeInstructionsNamespace<IDL, I, any>; // TODO: don't use any.
export type MethodFn<
IDL extends Idl = Idl,
I extends AllInstructions<IDL> = AllInstructions<IDL>
> = InstructionContextFn<IDL, I, MethodsBuilder<IDL, I>>;

View File

@ -65,6 +65,10 @@ export type MakeInstructionsNamespace<
Mk[M];
};
export type MakeMethodsNamespace<IDL extends Idl, I extends IdlInstruction> = {
[M in keyof InstructionMap<I>]: MethodsFn<IDL, InstructionMap<I>[M], any>;
};
export type InstructionContextFn<
IDL extends Idl,
I extends AllInstructions<IDL>,
@ -79,6 +83,12 @@ export type InstructionContextFnArgs<
Context<Accounts<I["accounts"][number]>>
];
export type MethodsFn<
IDL extends Idl,
I extends IDL["instructions"][number],
Ret
> = (...args: ArgsTuple<I["args"], IdlTypes<IDL>>) => Ret;
type TypeMap = {
publicKey: PublicKey;
bool: boolean;

View File

@ -8,12 +8,11 @@ const TOKEN_PROGRAM_ID = new PublicKey(
);
export function program(provider?: Provider): Program<SplToken> {
return new Program<SplToken>(
IDL,
TOKEN_PROGRAM_ID,
provider,
new SplTokenCoder(IDL)
);
return new Program<SplToken>(IDL, TOKEN_PROGRAM_ID, provider, coder());
}
export function coder(): SplTokenCoder {
return new SplTokenCoder(IDL);
}
/**

View File

@ -1,9 +1,9 @@
import { PublicKey } from "@solana/web3.js";
const TOKEN_PROGRAM_ID = new PublicKey(
export const TOKEN_PROGRAM_ID = new PublicKey(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
);
const ASSOCIATED_PROGRAM_ID = new PublicKey(
export const ASSOCIATED_PROGRAM_ID = new PublicKey(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
);