ソースを参照

feat: Add has_one relations inference so you don't need to pass accounts that are referenced by a has_one (#2160)

Noah Prince 3 年 前
コミット
e69e50daaf

+ 2 - 0
.github/workflows/no-cashing-tests.yaml

@@ -289,6 +289,8 @@ jobs:
             path: tests/multiple-suites
           - cmd: cd tests/pda-derivation && anchor test --skip-lint && npx tsc --noEmit
             path: tests/pda-derivation
+          - cmd: cd tests/relations-derivation && anchor test --skip-lint && npx tsc --noEmit
+            path: tests/relations-derivation
           - cmd: cd tests/anchor-cli-idl && ./test.sh
             path: tests/anchor-cli-idl
     steps:

+ 2 - 0
.github/workflows/tests.yaml

@@ -398,6 +398,8 @@ jobs:
             path: tests/multiple-suites
           - cmd: cd tests/pda-derivation && anchor test --skip-lint && npx tsc --noEmit
             path: tests/pda-derivation
+          - cmd: cd tests/relations-derivation && anchor test --skip-lint && npx tsc --noEmit
+            path: tests/relations-derivation
           - cmd: cd tests/anchor-cli-idl && ./test.sh
             path: tests/anchor-cli-idl
     steps:

+ 3 - 0
CHANGELOG.md

@@ -18,6 +18,9 @@ The minor version will be incremented upon a breaking change and the patch versi
 * lang: Add parsing for consts from impl blocks for IDL PDA seeds generation ([#2128](https://github.com/coral-xyz/anchor/pull/2014))
 * lang: Account closing reassigns to system program and reallocates ([#2169](https://github.com/coral-xyz/anchor/pull/2169)).
 * ts: Add coders for SPL programs ([#2143](https://github.com/coral-xyz/anchor/pull/2143)).
+* ts: Add `has_one` relations inference so accounts mapped via has_one relationships no longer need to be provided
+* ts: Add ability to set args after setting accounts and retriving pubkyes
+* ts: Add `.prepare()` to builder pattern
 * spl: Add `freeze_delegated_account` and `thaw_delegated_account` wrappers ([#2164](https://github.com/coral-xyz/anchor/pull/2164))
 
 ### Fixes

+ 1 - 0
lang/syn/src/idl/file.rs

@@ -645,6 +645,7 @@ fn idl_accounts(
                 },
                 docs: if !no_docs { acc.docs.clone() } else { None },
                 pda: pda::parse(ctx, accounts, acc, seeds_feature),
+                relations: relations::parse(acc, seeds_feature),
             }),
         })
         .collect::<Vec<_>>()

+ 3 - 0
lang/syn/src/idl/mod.rs

@@ -3,6 +3,7 @@ use serde_json::Value as JsonValue;
 
 pub mod file;
 pub mod pda;
+pub mod relations;
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
 pub struct Idl {
@@ -77,6 +78,8 @@ pub struct IdlAccount {
     pub docs: Option<Vec<String>>,
     #[serde(skip_serializing_if = "Option::is_none", default)]
     pub pda: Option<IdlPda>,
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub relations: Vec<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

+ 19 - 0
lang/syn/src/idl/relations.rs

@@ -0,0 +1,19 @@
+use crate::Field;
+use syn::Expr;
+
+pub fn parse(acc: &Field, seeds_feature: bool) -> Vec<String> {
+    if !seeds_feature {
+        return vec![];
+    }
+    acc.constraints
+        .has_one
+        .iter()
+        .flat_map(|s| match &s.join_target {
+            Expr::Path(path) => path.path.segments.first().map(|l| l.ident.to_string()),
+            _ => {
+                println!("WARNING: unexpected seed: {:?}", s);
+                None
+            }
+        })
+        .collect()
+}

+ 1 - 0
tests/package.json

@@ -23,6 +23,7 @@
     "multisig",
     "permissioned-markets",
     "pda-derivation",
+    "relations-derivation",
     "pyth",
     "realloc",
     "spl/token-proxy",

+ 15 - 0
tests/relations-derivation/Anchor.toml

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

+ 4 - 0
tests/relations-derivation/Cargo.toml

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

+ 22 - 0
tests/relations-derivation/migrations/deploy.ts

@@ -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.");
+};

+ 19 - 0
tests/relations-derivation/package.json

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

+ 19 - 0
tests/relations-derivation/programs/relations-derivation/Cargo.toml

@@ -0,0 +1,19 @@
+[package]
+name = "relations-derivation"
+version = "0.1.0"
+description = "Created with Anchor"
+rust-version = "1.56"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "lib"]
+name = "relations_derivation"
+
+[features]
+no-entrypoint = []
+no-idl = []
+cpi = ["no-entrypoint"]
+default = []
+
+[dependencies]
+anchor-lang = { path = "../../../../lang" }

+ 2 - 0
tests/relations-derivation/programs/relations-derivation/Xargo.toml

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

+ 68 - 0
tests/relations-derivation/programs/relations-derivation/src/lib.rs

@@ -0,0 +1,68 @@
+//! 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");
+
+#[program]
+pub mod relations_derivation {
+    use super::*;
+
+    pub fn init_base(ctx: Context<InitBase>) -> Result<()> {
+        ctx.accounts.account.my_account = ctx.accounts.my_account.key();
+        ctx.accounts.account.bump = ctx.bumps["account"];
+        Ok(())
+    }
+    pub fn test_relation(_ctx: Context<TestRelation>) -> Result<()> {
+        Ok(())
+    }
+}
+
+#[derive(Accounts)]
+pub struct InitBase<'info> {
+    /// CHECK: yeah I know
+    #[account(mut)]
+    my_account: Signer<'info>,
+    #[account(
+      init,
+      payer = my_account,
+      seeds = [b"seed"],
+      space = 100,
+      bump,
+    )]
+    account: Account<'info, MyAccount>,
+    system_program: Program<'info, System>
+}
+
+#[derive(Accounts)]
+pub struct Nested<'info> {
+    /// CHECK: yeah I know
+    my_account: UncheckedAccount<'info>,
+    #[account(
+      has_one = my_account,
+      seeds = [b"seed"],
+      bump = account.bump
+    )]
+    account: Account<'info, MyAccount>,
+}
+
+#[derive(Accounts)]
+pub struct TestRelation<'info> {
+    /// CHECK: yeah I know
+    my_account: UncheckedAccount<'info>,
+    #[account(
+      has_one = my_account,
+      seeds = [b"seed"],
+      bump = account.bump
+    )]
+    account: Account<'info, MyAccount>,
+    nested: Nested<'info>,
+}
+
+
+#[account]
+pub struct MyAccount {
+    pub my_account: Pubkey,
+    pub bump: u8
+}

+ 43 - 0
tests/relations-derivation/tests/typescript.spec.ts

@@ -0,0 +1,43 @@
+import * as anchor from "@project-serum/anchor";
+import { AnchorProvider, Program } from "@project-serum/anchor";
+import { PublicKey } from "@solana/web3.js";
+import { expect } from "chai";
+import { RelationsDerivation } from "../target/types/relations_derivation";
+
+describe("typescript", () => {
+  // Configure the client to use the local cluster.
+  anchor.setProvider(anchor.AnchorProvider.env());
+
+  const program = anchor.workspace
+    .RelationsDerivation as Program<RelationsDerivation>;
+  const provider = anchor.getProvider() as AnchorProvider;
+
+  it("Inits the base account", async () => {
+    await program.methods
+      .initBase()
+      .accounts({
+        myAccount: provider.wallet.publicKey,
+      })
+      .rpc();
+  });
+
+  it("Derives relationss", async () => {
+    const tx = await program.methods.testRelation().accounts({
+      nested: {
+        account: (
+          await PublicKey.findProgramAddress(
+            [Buffer.from("seed", "utf-8")],
+            program.programId
+          )
+        )[0],
+      },
+    });
+
+    await tx.instruction();
+    const keys = await tx.pubkeys();
+
+    expect(keys.myAccount.equals(provider.wallet.publicKey)).is.true;
+
+    await tx.rpc();
+  });
+});

+ 11 - 0
tests/relations-derivation/tsconfig.json

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

+ 12 - 0
ts/packages/anchor/src/coder/borsh/accounts.ts

@@ -63,6 +63,18 @@ export class BorshAccountsCoder<A extends string = string>
     return this.decodeUnchecked(accountName, data);
   }
 
+  public decodeAny<T = any>(data: Buffer): T {
+    const accountDescriminator = data.slice(0, 8);
+    const accountName = Array.from(this.accountLayouts.keys()).find((key) =>
+      BorshAccountsCoder.accountDiscriminator(key).equals(accountDescriminator)
+    );
+    if (!accountName) {
+      throw new Error("Account descriminator not found");
+    }
+
+    return this.decodeUnchecked<T>(accountName as any, data);
+  }
+
   public decodeUnchecked<T = any>(accountName: A, ix: Buffer): T {
     // Chop off the discriminator before decoding.
     const data = ix.slice(ACCOUNT_DISCRIMINATOR_SIZE);

+ 1 - 0
ts/packages/anchor/src/idl.ts

@@ -57,6 +57,7 @@ export type IdlAccount = {
   isMut: boolean;
   isSigner: boolean;
   docs?: string[];
+  relations?: string[];
   pda?: IdlPda;
 };
 

+ 109 - 8
ts/packages/anchor/src/program/accounts-resolver.ts

@@ -1,15 +1,25 @@
 import camelCase from "camelcase";
 import { PublicKey, SystemProgram, SYSVAR_RENT_PUBKEY } from "@solana/web3.js";
-import { Idl, IdlSeed, IdlAccount } from "../idl.js";
+import {
+  Idl,
+  IdlSeed,
+  IdlAccount,
+  IdlAccountItem,
+  IdlAccounts,
+} 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";
+import { BorshAccountsCoder } from "src/coder/index.js";
+
+type Accounts = { [name: string]: PublicKey | Accounts };
 
 // Populates a given accounts context with PDAs and common missing accounts.
 export class AccountsResolver<IDL extends Idl, I extends AllInstructions<IDL>> {
+  _args: Array<any>;
   static readonly CONST_ACCOUNTS = {
     associatedTokenProgram: ASSOCIATED_PROGRAM_ID,
     rent: SYSVAR_RENT_PUBKEY,
@@ -20,16 +30,21 @@ export class AccountsResolver<IDL extends Idl, I extends AllInstructions<IDL>> {
   private _accountStore: AccountStore<IDL>;
 
   constructor(
-    private _args: Array<any>,
-    private _accounts: { [name: string]: PublicKey },
+    _args: Array<any>,
+    private _accounts: Accounts,
     private _provider: Provider,
     private _programId: PublicKey,
     private _idlIx: AllInstructions<IDL>,
     _accountNamespace: AccountNamespace<IDL>
   ) {
+    this._args = _args;
     this._accountStore = new AccountStore(_provider, _accountNamespace);
   }
 
+  public args(_args: Array<any>): void {
+    this._args = _args;
+  }
+
   // 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.
@@ -85,6 +100,76 @@ export class AccountsResolver<IDL extends Idl, I extends AllInstructions<IDL>> {
         continue;
       }
     }
+
+    // Auto populate has_one relationships until we stop finding new accounts
+    while ((await this.resolveRelations(this._idlIx.accounts)) > 0) {}
+  }
+
+  private get(path: string[]): PublicKey | undefined {
+    // Only return if pubkey
+    const ret = path.reduce(
+      (acc, subPath) => acc && acc[subPath],
+      this._accounts
+    );
+
+    if (ret && ret.toBase58) {
+      return ret as PublicKey;
+    }
+  }
+
+  private set(path: string[], value: PublicKey): void {
+    let curr = this._accounts;
+    path.forEach((p, idx) => {
+      const isLast = idx == path.length - 1;
+      if (isLast) {
+        curr[p] = value;
+      }
+
+      curr[p] = curr[p] || {};
+      curr = curr[p] as Accounts;
+    });
+  }
+
+  private async resolveRelations(
+    accounts: IdlAccountItem[],
+    path: string[] = []
+  ): Promise<number> {
+    let found = 0;
+    for (let k = 0; k < accounts.length; k += 1) {
+      const accountDesc = accounts[k];
+      const subAccounts = (accountDesc as IdlAccounts).accounts;
+      if (subAccounts) {
+        found += await this.resolveRelations(subAccounts, [
+          ...path,
+          accountDesc.name,
+        ]);
+      }
+      const relations = (accountDesc as IdlAccount).relations || [];
+      const accountDescName = camelCase(accountDesc.name);
+      const newPath = [...path, accountDescName];
+
+      // If we have this account and there's some missing accounts that are relations to this account, fetch them
+      const accountKey = this.get(newPath);
+      if (accountKey) {
+        const matching = relations.filter(
+          (rel) => !this.get([...path, camelCase(rel)])
+        );
+
+        found += matching.length;
+        if (matching.length > 0) {
+          const account = await this._accountStore.fetchAccount(accountKey);
+          await Promise.all(
+            matching.map(async (rel) => {
+              const relName = camelCase(rel);
+
+              this.set([...path, relName], account[relName]);
+              return account[relName];
+            })
+          );
+        }
+      }
+    }
+    return found;
   }
 
   private async autoPopulatePda(accountDesc: IdlAccount) {
@@ -176,8 +261,8 @@ export class AccountsResolver<IDL extends Idl, I extends AllInstructions<IDL>> {
     //
     // Fetch and deserialize it.
     const account = await this._accountStore.fetchAccount(
-      seedDesc.account,
-      fieldPubkey
+      fieldPubkey as PublicKey,
+      seedDesc.account
     );
 
     // Dereference all fields in the path to get the field value
@@ -239,8 +324,8 @@ export class AccountStore<IDL extends Idl> {
   ) {}
 
   public async fetchAccount<T = any>(
-    name: string,
-    publicKey: PublicKey
+    publicKey: PublicKey,
+    name?: string
   ): Promise<T> {
     const address = publicKey.toString();
     if (!this._cache.has(address)) {
@@ -253,9 +338,25 @@ export class AccountStore<IDL extends Idl> {
         }
         const data = coder().accounts.decode("token", accountInfo.data);
         this._cache.set(address, data);
-      } else {
+      } else if (name) {
         const account = this._accounts[camelCase(name)].fetch(publicKey);
         this._cache.set(address, account);
+      } else {
+        const account = await this._provider.connection.getAccountInfo(
+          publicKey
+        );
+        if (account === null) {
+          throw new Error(`invalid account info for ${address}`);
+        }
+        const data = account.data;
+        const firstAccountLayout = Object.values(this._accounts)[0] as any;
+        if (!firstAccountLayout) {
+          throw new Error("No accounts for this program");
+        }
+        const result = (
+          firstAccountLayout.coder.accounts as BorshAccountsCoder
+        ).decodeAny(data);
+        this._cache.set(address, result);
       }
     }
     return this._cache.get(address);

+ 24 - 1
ts/packages/anchor/src/program/namespace/methods.ts

@@ -66,9 +66,10 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
   private _postInstructions: Array<TransactionInstruction> = [];
   private _accountsResolver: AccountsResolver<IDL, I>;
   private _autoResolveAccounts: boolean = true;
+  private _args: Array<any>;
 
   constructor(
-    private _args: Array<any>,
+    _args: Array<any>,
     private _ixFn: InstructionFn<IDL>,
     private _txFn: TransactionFn<IDL>,
     private _rpcFn: RpcFn<IDL>,
@@ -79,6 +80,7 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
     _idlIx: AllInstructions<IDL>,
     _accountNamespace: AccountNamespace<IDL>
   ) {
+    this._args = _args;
     this._accountsResolver = new AccountsResolver(
       _args,
       this._accounts,
@@ -89,6 +91,11 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
     );
   }
 
+  public args(_args: Array<any>): void {
+    this._args = _args;
+    this._accountsResolver.args(_args);
+  }
+
   public async pubkeys(): Promise<
     Partial<InstructionAccountAddresses<IDL, I>>
   > {
@@ -209,6 +216,22 @@ export class MethodsBuilder<IDL extends Idl, I extends AllInstructions<IDL>> {
     });
   }
 
+  /**
+   * Convenient shortcut to get instructions and pubkeys via
+   * const { pubkeys, instructions } = await prepare();
+   */
+  public async prepare(): Promise<{
+    pubkeys: Partial<InstructionAccountAddresses<IDL, I>>;
+    instruction: TransactionInstruction;
+    signers: Signer[];
+  }> {
+    return {
+      instruction: await this.instruction(),
+      pubkeys: await this.pubkeys(),
+      signers: await this._signers,
+    };
+  }
+
   public async transaction(): Promise<Transaction> {
     if (this._autoResolveAccounts) {
       await this._accountsResolver.resolve();