diff --git a/.github/workflows/no-cashing-tests.yaml b/.github/workflows/no-cashing-tests.yaml index 07be07025..2ee1c9a01 100644 --- a/.github/workflows/no-cashing-tests.yaml +++ b/.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: diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5dd219533..84d6b0677 100644 --- a/.github/workflows/tests.yaml +++ b/.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: diff --git a/CHANGELOG.md b/CHANGELOG.md index a15c6ed5b..dc2e2c2aa 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/lang/syn/src/idl/file.rs b/lang/syn/src/idl/file.rs index 23e813aa5..f10431b0c 100644 --- a/lang/syn/src/idl/file.rs +++ b/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::>() diff --git a/lang/syn/src/idl/mod.rs b/lang/syn/src/idl/mod.rs index b9220ef29..d47287ba0 100644 --- a/lang/syn/src/idl/mod.rs +++ b/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>, #[serde(skip_serializing_if = "Option::is_none", default)] pub pda: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub relations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/lang/syn/src/idl/relations.rs b/lang/syn/src/idl/relations.rs new file mode 100644 index 000000000..0d1108866 --- /dev/null +++ b/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 { + 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() +} diff --git a/tests/package.json b/tests/package.json index 022f87053..68b9f08ff 100644 --- a/tests/package.json +++ b/tests/package.json @@ -23,6 +23,7 @@ "multisig", "permissioned-markets", "pda-derivation", + "relations-derivation", "pyth", "realloc", "spl/token-proxy", diff --git a/tests/relations-derivation/Anchor.toml b/tests/relations-derivation/Anchor.toml new file mode 100644 index 000000000..ce936db25 --- /dev/null +++ b/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" diff --git a/tests/relations-derivation/Cargo.toml b/tests/relations-derivation/Cargo.toml new file mode 100644 index 000000000..a60de986d --- /dev/null +++ b/tests/relations-derivation/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = [ + "programs/*" +] diff --git a/tests/relations-derivation/migrations/deploy.ts b/tests/relations-derivation/migrations/deploy.ts new file mode 100644 index 000000000..53e1252d6 --- /dev/null +++ b/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 { + return new Promise((resolve) => { + setTimeout(() => { + console.log(exampleString); + resolve(); + }, 2000); + }); + } + + await deployAsync("Typescript migration example complete."); +}; diff --git a/tests/relations-derivation/package.json b/tests/relations-derivation/package.json new file mode 100644 index 000000000..d9c6113e3 --- /dev/null +++ b/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" + } +} diff --git a/tests/relations-derivation/programs/relations-derivation/Cargo.toml b/tests/relations-derivation/programs/relations-derivation/Cargo.toml new file mode 100644 index 000000000..a19c0925f --- /dev/null +++ b/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" } diff --git a/tests/relations-derivation/programs/relations-derivation/Xargo.toml b/tests/relations-derivation/programs/relations-derivation/Xargo.toml new file mode 100644 index 000000000..1744f098a --- /dev/null +++ b/tests/relations-derivation/programs/relations-derivation/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/tests/relations-derivation/programs/relations-derivation/src/lib.rs b/tests/relations-derivation/programs/relations-derivation/src/lib.rs new file mode 100644 index 000000000..d463044a3 --- /dev/null +++ b/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) -> 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) -> 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 +} diff --git a/tests/relations-derivation/tests/typescript.spec.ts b/tests/relations-derivation/tests/typescript.spec.ts new file mode 100644 index 000000000..24521d71f --- /dev/null +++ b/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; + 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(); + }); +}); diff --git a/tests/relations-derivation/tsconfig.json b/tests/relations-derivation/tsconfig.json new file mode 100644 index 000000000..b3b6656d3 --- /dev/null +++ b/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 + } +} diff --git a/ts/packages/anchor/src/coder/borsh/accounts.ts b/ts/packages/anchor/src/coder/borsh/accounts.ts index e2d021f0a..98da4cbb2 100644 --- a/ts/packages/anchor/src/coder/borsh/accounts.ts +++ b/ts/packages/anchor/src/coder/borsh/accounts.ts @@ -63,6 +63,18 @@ export class BorshAccountsCoder return this.decodeUnchecked(accountName, data); } + public decodeAny(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(accountName as any, data); + } + public decodeUnchecked(accountName: A, ix: Buffer): T { // Chop off the discriminator before decoding. const data = ix.slice(ACCOUNT_DISCRIMINATOR_SIZE); diff --git a/ts/packages/anchor/src/idl.ts b/ts/packages/anchor/src/idl.ts index b87968814..3366d57c9 100644 --- a/ts/packages/anchor/src/idl.ts +++ b/ts/packages/anchor/src/idl.ts @@ -57,6 +57,7 @@ export type IdlAccount = { isMut: boolean; isSigner: boolean; docs?: string[]; + relations?: string[]; pda?: IdlPda; }; diff --git a/ts/packages/anchor/src/program/accounts-resolver.ts b/ts/packages/anchor/src/program/accounts-resolver.ts index 455542c4b..186909de1 100644 --- a/ts/packages/anchor/src/program/accounts-resolver.ts +++ b/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> { + _args: Array; static readonly CONST_ACCOUNTS = { associatedTokenProgram: ASSOCIATED_PROGRAM_ID, rent: SYSVAR_RENT_PUBKEY, @@ -20,16 +30,21 @@ export class AccountsResolver> { private _accountStore: AccountStore; constructor( - private _args: Array, - private _accounts: { [name: string]: PublicKey }, + _args: Array, + private _accounts: Accounts, private _provider: Provider, private _programId: PublicKey, private _idlIx: AllInstructions, _accountNamespace: AccountNamespace ) { + this._args = _args; this._accountStore = new AccountStore(_provider, _accountNamespace); } + public args(_args: Array): 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> { 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 { + 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> { // // 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 { ) {} public async fetchAccount( - name: string, - publicKey: PublicKey + publicKey: PublicKey, + name?: string ): Promise { const address = publicKey.toString(); if (!this._cache.has(address)) { @@ -253,9 +338,25 @@ export class AccountStore { } 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); diff --git a/ts/packages/anchor/src/program/namespace/methods.ts b/ts/packages/anchor/src/program/namespace/methods.ts index 4cf88260b..f5a656f49 100644 --- a/ts/packages/anchor/src/program/namespace/methods.ts +++ b/ts/packages/anchor/src/program/namespace/methods.ts @@ -66,9 +66,10 @@ export class MethodsBuilder> { private _postInstructions: Array = []; private _accountsResolver: AccountsResolver; private _autoResolveAccounts: boolean = true; + private _args: Array; constructor( - private _args: Array, + _args: Array, private _ixFn: InstructionFn, private _txFn: TransactionFn, private _rpcFn: RpcFn, @@ -79,6 +80,7 @@ export class MethodsBuilder> { _idlIx: AllInstructions, _accountNamespace: AccountNamespace ) { + this._args = _args; this._accountsResolver = new AccountsResolver( _args, this._accounts, @@ -89,6 +91,11 @@ export class MethodsBuilder> { ); } + public args(_args: Array): void { + this._args = _args; + this._accountsResolver.args(_args); + } + public async pubkeys(): Promise< Partial> > { @@ -209,6 +216,22 @@ export class MethodsBuilder> { }); } + /** + * Convenient shortcut to get instructions and pubkeys via + * const { pubkeys, instructions } = await prepare(); + */ + public async prepare(): Promise<{ + pubkeys: Partial>; + instruction: TransactionInstruction; + signers: Signer[]; + }> { + return { + instruction: await this.instruction(), + pubkeys: await this.pubkeys(), + signers: await this._signers, + }; + } + public async transaction(): Promise { if (this._autoResolveAccounts) { await this._accountsResolver.resolve();