From 35ff806048ac71ec1dbcc78b6fd6ddac53dd756a Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:17:25 -0700 Subject: [PATCH 1/2] feat: resolve PDA defaults in instruction account builders Extract PDA resolution into a dedicated helper function with proper error handling: warn on unresolvable PDA nodes, bail entirely on missing seed values (instead of silently generating incomplete seeds), and propagate circular dependency fallbacks to all affected nodes. Also adds rawName to account-ref seeds so the template avoids fragile string surgery, removes a spurious `use solana_address;` import, and adds tests for programIdValueNode seeds, bytesTypeNode seeds, linked PDAs with variable account seeds, circular dependencies, and optional+PDA default interaction. --- .../generated/instructions/create_guard.rs | 70 ++- .../src/generated/instructions/execute.rs | 49 +- .../src/generated/instructions/initialize.rs | 44 +- .../generated/instructions/update_guard.rs | 56 +- .../generated/instructions/instruction6.rs | 6 +- .../generated/instructions/instruction7.rs | 6 +- .../instructions/advance_nonce_account.rs | 16 +- .../src/generated/instructions/allocate.rs | 6 +- .../instructions/allocate_with_seed.rs | 7 +- .../src/generated/instructions/assign.rs | 6 +- .../instructions/assign_with_seed.rs | 7 +- .../instructions/authorize_nonce_account.rs | 7 +- .../generated/instructions/create_account.rs | 8 +- .../instructions/create_account_with_seed.rs | 10 +- .../instructions/initialize_nonce_account.rs | 20 +- .../generated/instructions/transfer_sol.rs | 7 +- .../instructions/transfer_sol_with_seed.rs | 10 +- .../instructions/upgrade_nonce_account.rs | 6 +- .../instructions/withdraw_nonce_account.rs | 30 +- public/templates/instructionsPageBuilder.njk | 58 +- src/getRenderMapVisitor.ts | 252 +++++++++ test/instructionsPage.test.ts | 504 +++++++++++++++++- 22 files changed, 1057 insertions(+), 128 deletions(-) diff --git a/e2e/anchor/src/generated/instructions/create_guard.rs b/e2e/anchor/src/generated/instructions/create_guard.rs index fd28a57..24002a6 100644 --- a/e2e/anchor/src/generated/instructions/create_guard.rs +++ b/e2e/anchor/src/generated/instructions/create_guard.rs @@ -124,9 +124,9 @@ impl CreateGuardInstructionArgs { /// /// ### Accounts: /// -/// 0. `[writable]` guard +/// 0. `[writable, optional]` guard (default to PDA) /// 1. `[writable, signer]` mint -/// 2. `[writable]` mint_token_account +/// 2. `[writable, optional]` mint_token_account (default to PDA) /// 3. `[signer]` guard_authority /// 4. `[writable, signer]` payer /// 5. `[optional]` associated_token_program (default to `ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL`) @@ -155,6 +155,7 @@ impl CreateGuardBuilder { pub fn new() -> Self { Self::default() } + /// `[optional account, default to PDA]` #[inline(always)] pub fn guard(&mut self, guard: solana_address::Address) -> &mut Self { self.guard = Some(guard); @@ -165,6 +166,7 @@ impl CreateGuardBuilder { self.mint = Some(mint); self } + /// `[optional account, default to PDA]` #[inline(always)] pub fn mint_token_account(&mut self, mint_token_account: solana_address::Address) -> &mut Self { self.mint_token_account = Some(mint_token_account); @@ -253,23 +255,55 @@ impl CreateGuardBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let mint = self.mint.expect("mint is not set"); + let guard = self.guard.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + &[ + 119, 101, 110, 95, 116, 111, 107, 101, 110, 95, 116, 114, 97, 110, 115, + 102, 101, 114, 95, 103, 117, 97, 114, 100, + ], + &[103, 117, 97, 114, 100, 95, 118, 49], + mint.as_ref(), + ], + &crate::WEN_TRANSFER_GUARD_ID, + ) + .0 + }); + let guard_authority = self.guard_authority.expect("guard_authority is not set"); + let token_program = self.token_program.unwrap_or(solana_address::address!( + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + )); + let mint_token_account = self.mint_token_account.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + guard_authority.as_ref(), + token_program.as_ref(), + mint.as_ref(), + ], + &solana_address::address!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"), + ) + .0 + }); + let payer = self.payer.expect("payer is not set"); + let associated_token_program = + self.associated_token_program + .unwrap_or(solana_address::address!( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + )); + let system_program = self + .system_program + .unwrap_or(solana_address::address!("11111111111111111111111111111111")); + let accounts = CreateGuard { - guard: self.guard.expect("guard is not set"), - mint: self.mint.expect("mint is not set"), - mint_token_account: self - .mint_token_account - .expect("mint_token_account is not set"), - guard_authority: self.guard_authority.expect("guard_authority is not set"), - payer: self.payer.expect("payer is not set"), - associated_token_program: self.associated_token_program.unwrap_or( - solana_address::address!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"), - ), - token_program: self.token_program.unwrap_or(solana_address::address!( - "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" - )), - system_program: self - .system_program - .unwrap_or(solana_address::address!("11111111111111111111111111111111")), + guard, + mint, + mint_token_account, + guard_authority, + payer, + associated_token_program, + token_program, + system_program, }; let args = CreateGuardInstructionArgs { name: self.name.clone().expect("name is not set"), diff --git a/e2e/anchor/src/generated/instructions/execute.rs b/e2e/anchor/src/generated/instructions/execute.rs index 410cbe4..9643769 100644 --- a/e2e/anchor/src/generated/instructions/execute.rs +++ b/e2e/anchor/src/generated/instructions/execute.rs @@ -121,7 +121,7 @@ impl ExecuteInstructionArgs { /// 1. `[]` mint /// 2. `[]` destination_account /// 3. `[]` owner_delegate -/// 4. `[]` extra_metas_account +/// 4. `[optional]` extra_metas_account (default to PDA) /// 5. `[]` guard /// 6. `[optional]` instruction_sysvar_account (default to `Sysvar1nstructions1111111111111111111111111`) #[derive(Clone, Debug, Default)] @@ -164,6 +164,7 @@ impl ExecuteBuilder { self.owner_delegate = Some(owner_delegate); self } + /// `[optional account, default to PDA]` #[inline(always)] pub fn extra_metas_account( &mut self, @@ -208,20 +209,40 @@ impl ExecuteBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let source_account = self.source_account.expect("source_account is not set"); + let mint = self.mint.expect("mint is not set"); + let destination_account = self + .destination_account + .expect("destination_account is not set"); + let owner_delegate = self.owner_delegate.expect("owner_delegate is not set"); + let extra_metas_account = self.extra_metas_account.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + &[ + 101, 120, 116, 114, 97, 45, 97, 99, 99, 111, 117, 110, 116, 45, 109, 101, + 116, 97, 115, + ], + mint.as_ref(), + ], + &crate::WEN_TRANSFER_GUARD_ID, + ) + .0 + }); + let guard = self.guard.expect("guard is not set"); + let instruction_sysvar_account = + self.instruction_sysvar_account + .unwrap_or(solana_address::address!( + "Sysvar1nstructions1111111111111111111111111" + )); + let accounts = Execute { - source_account: self.source_account.expect("source_account is not set"), - mint: self.mint.expect("mint is not set"), - destination_account: self - .destination_account - .expect("destination_account is not set"), - owner_delegate: self.owner_delegate.expect("owner_delegate is not set"), - extra_metas_account: self - .extra_metas_account - .expect("extra_metas_account is not set"), - guard: self.guard.expect("guard is not set"), - instruction_sysvar_account: self.instruction_sysvar_account.unwrap_or( - solana_address::address!("Sysvar1nstructions1111111111111111111111111"), - ), + source_account, + mint, + destination_account, + owner_delegate, + extra_metas_account, + guard, + instruction_sysvar_account, }; let args = ExecuteInstructionArgs { amount: self.amount.clone().expect("amount is not set"), diff --git a/e2e/anchor/src/generated/instructions/initialize.rs b/e2e/anchor/src/generated/instructions/initialize.rs index ce30b3c..3326ba9 100644 --- a/e2e/anchor/src/generated/instructions/initialize.rs +++ b/e2e/anchor/src/generated/instructions/initialize.rs @@ -94,7 +94,7 @@ impl Default for InitializeInstructionData { /// /// ### Accounts: /// -/// 0. `[writable]` extra_metas_account +/// 0. `[writable, optional]` extra_metas_account (default to PDA) /// 1. `[]` guard /// 2. `[]` mint /// 3. `[writable, signer]` transfer_hook_authority @@ -115,6 +115,7 @@ impl InitializeBuilder { pub fn new() -> Self { Self::default() } + /// `[optional account, default to PDA]` #[inline(always)] pub fn extra_metas_account( &mut self, @@ -169,19 +170,36 @@ impl InitializeBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let mint = self.mint.expect("mint is not set"); + let extra_metas_account = self.extra_metas_account.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + &[ + 101, 120, 116, 114, 97, 45, 97, 99, 99, 111, 117, 110, 116, 45, 109, 101, + 116, 97, 115, + ], + mint.as_ref(), + ], + &crate::WEN_TRANSFER_GUARD_ID, + ) + .0 + }); + let guard = self.guard.expect("guard is not set"); + let transfer_hook_authority = self + .transfer_hook_authority + .expect("transfer_hook_authority is not set"); + let system_program = self + .system_program + .unwrap_or(solana_address::address!("11111111111111111111111111111111")); + let payer = self.payer.expect("payer is not set"); + let accounts = Initialize { - extra_metas_account: self - .extra_metas_account - .expect("extra_metas_account is not set"), - guard: self.guard.expect("guard is not set"), - mint: self.mint.expect("mint is not set"), - transfer_hook_authority: self - .transfer_hook_authority - .expect("transfer_hook_authority is not set"), - system_program: self - .system_program - .unwrap_or(solana_address::address!("11111111111111111111111111111111")), - payer: self.payer.expect("payer is not set"), + extra_metas_account, + guard, + mint, + transfer_hook_authority, + system_program, + payer, }; accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) diff --git a/e2e/anchor/src/generated/instructions/update_guard.rs b/e2e/anchor/src/generated/instructions/update_guard.rs index 9eb70a1..e10b9de 100644 --- a/e2e/anchor/src/generated/instructions/update_guard.rs +++ b/e2e/anchor/src/generated/instructions/update_guard.rs @@ -114,9 +114,9 @@ impl UpdateGuardInstructionArgs { /// /// ### Accounts: /// -/// 0. `[writable]` guard +/// 0. `[writable, optional]` guard (default to PDA) /// 1. `[]` mint -/// 2. `[]` token_account +/// 2. `[optional]` token_account (default to PDA) /// 3. `[signer]` guard_authority /// 4. `[optional]` token_program (default to `TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb`) /// 5. `[optional]` system_program (default to `11111111111111111111111111111111`) @@ -138,6 +138,7 @@ impl UpdateGuardBuilder { pub fn new() -> Self { Self::default() } + /// `[optional account, default to PDA]` #[inline(always)] pub fn guard(&mut self, guard: solana_address::Address) -> &mut Self { self.guard = Some(guard); @@ -148,6 +149,7 @@ impl UpdateGuardBuilder { self.mint = Some(mint); self } + /// `[optional account, default to PDA]` #[inline(always)] pub fn token_account(&mut self, token_account: solana_address::Address) -> &mut Self { self.token_account = Some(token_account); @@ -207,17 +209,47 @@ impl UpdateGuardBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let mint = self.mint.expect("mint is not set"); + let guard = self.guard.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + &[ + 119, 101, 110, 95, 116, 111, 107, 101, 110, 95, 116, 114, 97, 110, 115, + 102, 101, 114, 95, 103, 117, 97, 114, 100, + ], + &[103, 117, 97, 114, 100, 95, 118, 49], + mint.as_ref(), + ], + &crate::WEN_TRANSFER_GUARD_ID, + ) + .0 + }); + let guard_authority = self.guard_authority.expect("guard_authority is not set"); + let token_program = self.token_program.unwrap_or(solana_address::address!( + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + )); + let token_account = self.token_account.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + guard_authority.as_ref(), + token_program.as_ref(), + mint.as_ref(), + ], + &solana_address::address!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"), + ) + .0 + }); + let system_program = self + .system_program + .unwrap_or(solana_address::address!("11111111111111111111111111111111")); + let accounts = UpdateGuard { - guard: self.guard.expect("guard is not set"), - mint: self.mint.expect("mint is not set"), - token_account: self.token_account.expect("token_account is not set"), - guard_authority: self.guard_authority.expect("guard_authority is not set"), - token_program: self.token_program.unwrap_or(solana_address::address!( - "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" - )), - system_program: self - .system_program - .unwrap_or(solana_address::address!("11111111111111111111111111111111")), + guard, + mint, + token_account, + guard_authority, + token_program, + system_program, }; let args = UpdateGuardInstructionArgs { cpi_rule: self.cpi_rule.clone(), diff --git a/e2e/dummy/src/generated/instructions/instruction6.rs b/e2e/dummy/src/generated/instructions/instruction6.rs index a6762bf..0620361 100644 --- a/e2e/dummy/src/generated/instructions/instruction6.rs +++ b/e2e/dummy/src/generated/instructions/instruction6.rs @@ -93,9 +93,9 @@ impl Instruction6Builder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { - let accounts = Instruction6 { - my_account: self.my_account.expect("my_account is not set"), - }; + let my_account = self.my_account.expect("my_account is not set"); + + let accounts = Instruction6 { my_account }; accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) } diff --git a/e2e/dummy/src/generated/instructions/instruction7.rs b/e2e/dummy/src/generated/instructions/instruction7.rs index fff4818..771a3f8 100644 --- a/e2e/dummy/src/generated/instructions/instruction7.rs +++ b/e2e/dummy/src/generated/instructions/instruction7.rs @@ -101,9 +101,9 @@ impl Instruction7Builder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { - let accounts = Instruction7 { - my_account: self.my_account, - }; + let my_account = self.my_account; + + let accounts = Instruction7 { my_account }; accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) } diff --git a/e2e/system/src/generated/instructions/advance_nonce_account.rs b/e2e/system/src/generated/instructions/advance_nonce_account.rs index 4858fa9..53851c3 100644 --- a/e2e/system/src/generated/instructions/advance_nonce_account.rs +++ b/e2e/system/src/generated/instructions/advance_nonce_account.rs @@ -132,12 +132,18 @@ impl AdvanceNonceAccountBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let nonce_account = self.nonce_account.expect("nonce_account is not set"); + let recent_blockhashes_sysvar = + self.recent_blockhashes_sysvar + .unwrap_or(solana_address::address!( + "SysvarRecentB1ockHashes11111111111111111111" + )); + let nonce_authority = self.nonce_authority.expect("nonce_authority is not set"); + let accounts = AdvanceNonceAccount { - nonce_account: self.nonce_account.expect("nonce_account is not set"), - recent_blockhashes_sysvar: self.recent_blockhashes_sysvar.unwrap_or( - solana_address::address!("SysvarRecentB1ockHashes11111111111111111111"), - ), - nonce_authority: self.nonce_authority.expect("nonce_authority is not set"), + nonce_account, + recent_blockhashes_sysvar, + nonce_authority, }; accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) diff --git a/e2e/system/src/generated/instructions/allocate.rs b/e2e/system/src/generated/instructions/allocate.rs index ef7989f..46ae45b 100644 --- a/e2e/system/src/generated/instructions/allocate.rs +++ b/e2e/system/src/generated/instructions/allocate.rs @@ -117,9 +117,9 @@ impl AllocateBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { - let accounts = Allocate { - new_account: self.new_account.expect("new_account is not set"), - }; + let new_account = self.new_account.expect("new_account is not set"); + + let accounts = Allocate { new_account }; let args = AllocateInstructionArgs { space: self.space.clone().expect("space is not set"), }; diff --git a/e2e/system/src/generated/instructions/allocate_with_seed.rs b/e2e/system/src/generated/instructions/allocate_with_seed.rs index 0064540..51fbfd8 100644 --- a/e2e/system/src/generated/instructions/allocate_with_seed.rs +++ b/e2e/system/src/generated/instructions/allocate_with_seed.rs @@ -158,9 +158,12 @@ impl AllocateWithSeedBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let new_account = self.new_account.expect("new_account is not set"); + let base_account = self.base_account.expect("base_account is not set"); + let accounts = AllocateWithSeed { - new_account: self.new_account.expect("new_account is not set"), - base_account: self.base_account.expect("base_account is not set"), + new_account, + base_account, }; let args = AllocateWithSeedInstructionArgs { base: self.base.clone().expect("base is not set"), diff --git a/e2e/system/src/generated/instructions/assign.rs b/e2e/system/src/generated/instructions/assign.rs index 4ccc263..ceda65c 100644 --- a/e2e/system/src/generated/instructions/assign.rs +++ b/e2e/system/src/generated/instructions/assign.rs @@ -118,9 +118,9 @@ impl AssignBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { - let accounts = Assign { - account: self.account.expect("account is not set"), - }; + let account = self.account.expect("account is not set"); + + let accounts = Assign { account }; let args = AssignInstructionArgs { program_address: self .program_address diff --git a/e2e/system/src/generated/instructions/assign_with_seed.rs b/e2e/system/src/generated/instructions/assign_with_seed.rs index bd88c2f..1c0aaec 100644 --- a/e2e/system/src/generated/instructions/assign_with_seed.rs +++ b/e2e/system/src/generated/instructions/assign_with_seed.rs @@ -148,9 +148,12 @@ impl AssignWithSeedBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let account = self.account.expect("account is not set"); + let base_account = self.base_account.expect("base_account is not set"); + let accounts = AssignWithSeed { - account: self.account.expect("account is not set"), - base_account: self.base_account.expect("base_account is not set"), + account, + base_account, }; let args = AssignWithSeedInstructionArgs { base: self.base.clone().expect("base is not set"), diff --git a/e2e/system/src/generated/instructions/authorize_nonce_account.rs b/e2e/system/src/generated/instructions/authorize_nonce_account.rs index e881144..22d16d7 100644 --- a/e2e/system/src/generated/instructions/authorize_nonce_account.rs +++ b/e2e/system/src/generated/instructions/authorize_nonce_account.rs @@ -139,9 +139,12 @@ impl AuthorizeNonceAccountBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let nonce_account = self.nonce_account.expect("nonce_account is not set"); + let nonce_authority = self.nonce_authority.expect("nonce_authority is not set"); + let accounts = AuthorizeNonceAccount { - nonce_account: self.nonce_account.expect("nonce_account is not set"), - nonce_authority: self.nonce_authority.expect("nonce_authority is not set"), + nonce_account, + nonce_authority, }; let args = AuthorizeNonceAccountInstructionArgs { new_nonce_authority: self diff --git a/e2e/system/src/generated/instructions/create_account.rs b/e2e/system/src/generated/instructions/create_account.rs index b455e4c..cc4203c 100644 --- a/e2e/system/src/generated/instructions/create_account.rs +++ b/e2e/system/src/generated/instructions/create_account.rs @@ -145,10 +145,10 @@ impl CreateAccountBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { - let accounts = CreateAccount { - payer: self.payer.expect("payer is not set"), - new_account: self.new_account.expect("new_account is not set"), - }; + let payer = self.payer.expect("payer is not set"); + let new_account = self.new_account.expect("new_account is not set"); + + let accounts = CreateAccount { payer, new_account }; let args = CreateAccountInstructionArgs { lamports: self.lamports.clone().expect("lamports is not set"), space: self.space.clone().expect("space is not set"), diff --git a/e2e/system/src/generated/instructions/create_account_with_seed.rs b/e2e/system/src/generated/instructions/create_account_with_seed.rs index 97676e7..5016048 100644 --- a/e2e/system/src/generated/instructions/create_account_with_seed.rs +++ b/e2e/system/src/generated/instructions/create_account_with_seed.rs @@ -177,10 +177,14 @@ impl CreateAccountWithSeedBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let payer = self.payer.expect("payer is not set"); + let new_account = self.new_account.expect("new_account is not set"); + let base_account = self.base_account.expect("base_account is not set"); + let accounts = CreateAccountWithSeed { - payer: self.payer.expect("payer is not set"), - new_account: self.new_account.expect("new_account is not set"), - base_account: self.base_account.expect("base_account is not set"), + payer, + new_account, + base_account, }; let args = CreateAccountWithSeedInstructionArgs { base: self.base.clone().expect("base is not set"), diff --git a/e2e/system/src/generated/instructions/initialize_nonce_account.rs b/e2e/system/src/generated/instructions/initialize_nonce_account.rs index f453f48..9b8aa23 100644 --- a/e2e/system/src/generated/instructions/initialize_nonce_account.rs +++ b/e2e/system/src/generated/instructions/initialize_nonce_account.rs @@ -157,14 +157,20 @@ impl InitializeNonceAccountBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let nonce_account = self.nonce_account.expect("nonce_account is not set"); + let recent_blockhashes_sysvar = + self.recent_blockhashes_sysvar + .unwrap_or(solana_address::address!( + "SysvarRecentB1ockHashes11111111111111111111" + )); + let rent_sysvar = self.rent_sysvar.unwrap_or(solana_address::address!( + "SysvarRent111111111111111111111111111111111" + )); + let accounts = InitializeNonceAccount { - nonce_account: self.nonce_account.expect("nonce_account is not set"), - recent_blockhashes_sysvar: self.recent_blockhashes_sysvar.unwrap_or( - solana_address::address!("SysvarRecentB1ockHashes11111111111111111111"), - ), - rent_sysvar: self.rent_sysvar.unwrap_or(solana_address::address!( - "SysvarRent111111111111111111111111111111111" - )), + nonce_account, + recent_blockhashes_sysvar, + rent_sysvar, }; let args = InitializeNonceAccountInstructionArgs { nonce_authority: self diff --git a/e2e/system/src/generated/instructions/transfer_sol.rs b/e2e/system/src/generated/instructions/transfer_sol.rs index 6403821..be461db 100644 --- a/e2e/system/src/generated/instructions/transfer_sol.rs +++ b/e2e/system/src/generated/instructions/transfer_sol.rs @@ -130,9 +130,12 @@ impl TransferSolBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let source = self.source.expect("source is not set"); + let destination = self.destination.expect("destination is not set"); + let accounts = TransferSol { - source: self.source.expect("source is not set"), - destination: self.destination.expect("destination is not set"), + source, + destination, }; let args = TransferSolInstructionArgs { amount: self.amount.clone().expect("amount is not set"), diff --git a/e2e/system/src/generated/instructions/transfer_sol_with_seed.rs b/e2e/system/src/generated/instructions/transfer_sol_with_seed.rs index 3fd3668..edb09cc 100644 --- a/e2e/system/src/generated/instructions/transfer_sol_with_seed.rs +++ b/e2e/system/src/generated/instructions/transfer_sol_with_seed.rs @@ -163,10 +163,14 @@ impl TransferSolWithSeedBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let source = self.source.expect("source is not set"); + let base_account = self.base_account.expect("base_account is not set"); + let destination = self.destination.expect("destination is not set"); + let accounts = TransferSolWithSeed { - source: self.source.expect("source is not set"), - base_account: self.base_account.expect("base_account is not set"), - destination: self.destination.expect("destination is not set"), + source, + base_account, + destination, }; let args = TransferSolWithSeedInstructionArgs { amount: self.amount.clone().expect("amount is not set"), diff --git a/e2e/system/src/generated/instructions/upgrade_nonce_account.rs b/e2e/system/src/generated/instructions/upgrade_nonce_account.rs index 7de98bc..d438d12 100644 --- a/e2e/system/src/generated/instructions/upgrade_nonce_account.rs +++ b/e2e/system/src/generated/instructions/upgrade_nonce_account.rs @@ -102,9 +102,9 @@ impl UpgradeNonceAccountBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { - let accounts = UpgradeNonceAccount { - nonce_account: self.nonce_account.expect("nonce_account is not set"), - }; + let nonce_account = self.nonce_account.expect("nonce_account is not set"); + + let accounts = UpgradeNonceAccount { nonce_account }; accounts.instruction_with_remaining_accounts(&self.__remaining_accounts) } diff --git a/e2e/system/src/generated/instructions/withdraw_nonce_account.rs b/e2e/system/src/generated/instructions/withdraw_nonce_account.rs index da43d7d..9c02fd4 100644 --- a/e2e/system/src/generated/instructions/withdraw_nonce_account.rs +++ b/e2e/system/src/generated/instructions/withdraw_nonce_account.rs @@ -182,18 +182,26 @@ impl WithdrawNonceAccountBuilder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + let nonce_account = self.nonce_account.expect("nonce_account is not set"); + let recipient_account = self + .recipient_account + .expect("recipient_account is not set"); + let recent_blockhashes_sysvar = + self.recent_blockhashes_sysvar + .unwrap_or(solana_address::address!( + "SysvarRecentB1ockHashes11111111111111111111" + )); + let rent_sysvar = self.rent_sysvar.unwrap_or(solana_address::address!( + "SysvarRent111111111111111111111111111111111" + )); + let nonce_authority = self.nonce_authority.expect("nonce_authority is not set"); + let accounts = WithdrawNonceAccount { - nonce_account: self.nonce_account.expect("nonce_account is not set"), - recipient_account: self - .recipient_account - .expect("recipient_account is not set"), - recent_blockhashes_sysvar: self.recent_blockhashes_sysvar.unwrap_or( - solana_address::address!("SysvarRecentB1ockHashes11111111111111111111"), - ), - rent_sysvar: self.rent_sysvar.unwrap_or(solana_address::address!( - "SysvarRent111111111111111111111111111111111" - )), - nonce_authority: self.nonce_authority.expect("nonce_authority is not set"), + nonce_account, + recipient_account, + recent_blockhashes_sysvar, + rent_sysvar, + nonce_authority, }; let args = WithdrawNonceAccountInstructionArgs { withdraw_amount: self diff --git a/public/templates/instructionsPageBuilder.njk b/public/templates/instructionsPageBuilder.njk index 44b3682..9c0074d 100644 --- a/public/templates/instructionsPageBuilder.njk +++ b/public/templates/instructionsPageBuilder.njk @@ -10,11 +10,12 @@ {% if account.isSigner %} {% set modifiers = modifiers + ', signer' if modifiers.length > 0 else 'signer' %} {% endif %} - {% if account.isOptional or account.defaultValue.kind === 'publicKeyValueNode' %} + {% if account.isOptional or account.defaultValue.kind === 'publicKeyValueNode' or account.defaultValue.kind === 'pdaValueNode' %} {% set modifiers = modifiers + ', optional' if modifiers.length > 0 else 'optional' %} {% endif %} {{ '/// ' + loop.index0 + '. `[' + modifiers + ']` ' + account.name | snakeCase }} {{- " (default to `" + account.defaultValue.publicKey + "`)" if account.defaultValue.kind === 'publicKeyValueNode' }} + {{- " (default to PDA)" if account.defaultValue.kind === 'pdaValueNode' }} {% endfor %} #[derive(Clone, Debug, Default)] pub struct {{ instruction.name | pascalCase }}Builder { @@ -40,8 +41,10 @@ impl {{ instruction.name | pascalCase }}Builder { {% for account in instruction.accounts %} {% if account.isOptional %} {{ '/// `[optional account]`\n' -}} - {% else %} - {{ "/// `[optional account, default to '" + account.defaultValue.publicKey + "']`\n" if account.defaultValue.kind === 'publicKeyValueNode' -}} + {% elif account.defaultValue.kind === 'publicKeyValueNode' %} + {{ "/// `[optional account, default to '" + account.defaultValue.publicKey + "']`\n" -}} + {% elif account.defaultValue.kind === 'pdaValueNode' %} + {{ '/// `[optional account, default to PDA]`\n' -}} {% endif %} {{- macros.docblock(account.docs) -}} #[inline(always)] @@ -92,17 +95,48 @@ impl {{ instruction.name | pascalCase }}Builder { } #[allow(clippy::clone_on_copy)] pub fn instruction(&self) -> solana_instruction::Instruction { + {% for account in resolvedAccounts %} + {% if account.isOptional %} + let {{ account.name | snakeCase }} = self.{{ account.name | snakeCase }}; + {% elif account.defaultValue.kind === 'programId' %} + let {{ account.name | snakeCase }} = self.{{ account.name | snakeCase }}; {# Program ID set on the instruction creation. #} + {% elif account.pdaDefault %} + {% if account.pdaDefault.isLinked and account.pdaDefault.linkedAccountName %} + let {{ account.name | snakeCase }} = self.{{ account.name | snakeCase }}.unwrap_or_else(|| { + {{ account.pdaDefault.linkedAccountName | pascalCase }}::find_pda( + {% for seed in account.pdaDefault.renderedSeeds %} + {% if seed.kind === 'accountRef' %} + &{{ seed.rawName }}, + {% elif seed.kind === 'argumentRef' %} + {{ seed.render }}, + {% elif seed.kind === 'value' %} + {{ seed.render }}, + {% endif %} + {% endfor %} + ).0 + }); + {% else %} + let {{ account.name | snakeCase }} = self.{{ account.name | snakeCase }}.unwrap_or_else(|| { + solana_address::Address::find_program_address( + &[ + {% for seed in account.pdaDefault.renderedSeeds %} + {{ seed.render }}, + {% endfor %} + ], + &{{ account.pdaDefault.programAddressExpr }}, + ).0 + }); + {% endif %} + {% elif account.defaultValue.kind === 'publicKeyValueNode' %} + let {{ account.name | snakeCase }} = self.{{ account.name | snakeCase }}.unwrap_or(solana_address::address!("{{ account.defaultValue.publicKey }}")); + {% else %} + let {{ account.name | snakeCase }} = self.{{ account.name | snakeCase }}.expect("{{ account.name | snakeCase }} is not set"); + {% endif %} + {% endfor %} + let accounts = {{ instruction.name | pascalCase }} { {% for account in instruction.accounts %} - {% if account.isOptional %} - {{ account.name | snakeCase }}: self.{{ account.name | snakeCase }}, - {% elif account.defaultValue.kind === 'programId' %} - {{ account.name | snakeCase }}: self.{{ account.name | snakeCase }}, {# Program ID set on the instruction creation. #} - {% elif account.defaultValue.kind === 'publicKeyValueNode' %} - {{ account.name | snakeCase }}: self.{{ account.name | snakeCase }}.unwrap_or(solana_address::address!("{{ account.defaultValue.publicKey }}")), - {% else %} - {{ account.name | snakeCase }}: self.{{ account.name | snakeCase }}.expect("{{ account.name | snakeCase }} is not set"), - {% endif %} + {{ account.name | snakeCase }}, {% endfor %} }; {% if hasArgs %} diff --git a/src/getRenderMapVisitor.ts b/src/getRenderMapVisitor.ts index 55e65a1..def0995 100644 --- a/src/getRenderMapVisitor.ts +++ b/src/getRenderMapVisitor.ts @@ -1,13 +1,16 @@ import { logWarn } from '@codama/errors'; import { + camelCase, getAllAccounts, getAllDefinedTypes, getAllInstructionsWithSubs, getAllPrograms, + type InstructionAccountNode, InstructionNode, isNode, isNodeFilter, pascalCase, + PdaNode, ProgramNode, resolveNestedTypeNode, snakeCase, @@ -34,6 +37,7 @@ import { Fragment, getDiscriminatorConstants, getImportFromFactory, + type GetImportFromFunction, getTraitsFromNodeFactory, LinkOverrides, render, @@ -233,6 +237,17 @@ export function getRenderMapVisitor(options: GetRenderMapOptions = {}) { }); const typeManifest = visit(struct, structVisitor); + // Resolve PDA defaults and topologically sort accounts by dependency. + const resolvedAccounts = resolveInstructionPdaDefaults({ + accounts: node.accounts, + getImportFrom, + imports, + instructionName: node.name, + linkables, + program: program!, + stack, + }); + const dataTraits = getTraitsFromNode(node); imports .mergeWith(dataTraits.imports) @@ -249,6 +264,7 @@ export function getRenderMapVisitor(options: GetRenderMapOptions = {}) { instruction: node, instructionArgs, program, + resolvedAccounts, typeManifest, }), imports, @@ -340,6 +356,242 @@ export function getRenderMapVisitor(options: GetRenderMapOptions = {}) { ); } +type RenderedSeed = { + kind: 'accountRef' | 'argumentRef' | 'constant' | 'programId' | 'value'; + rawName?: string; + render: string; +}; + +type ResolvedPdaInfo = { + accountDeps: string[]; + isLinked: boolean; + linkedAccountName?: string; + linkedImportFrom?: string; + programAddressExpr: string; + renderedSeeds: RenderedSeed[]; +}; + +type ResolvedAccount = InstructionAccountNode & { pdaDefault: ResolvedPdaInfo | null }; + +function resolveInstructionPdaDefaults(ctx: { + accounts: readonly InstructionAccountNode[]; + getImportFrom: GetImportFromFunction; + imports: ImportMap; + instructionName: string; + linkables: LinkableDictionary; + program: ProgramNode; + stack: NodeStack; +}): ResolvedAccount[] { + const { accounts, getImportFrom, imports, instructionName, linkables, program, stack } = ctx; + + const resolvedPdaAccounts: Record = {}; + + for (const account of accounts) { + if (!account.defaultValue || !isNode(account.defaultValue, 'pdaValueNode')) { + continue; + } + + const defaultValue = account.defaultValue; + let pdaNode: PdaNode | undefined; + let isLinked = false; + let linkedAccountName: string | undefined; + let linkedImportFrom: string | undefined; + + if (isNode(defaultValue.pda, 'pdaLinkNode')) { + pdaNode = linkables.get([...stack.getPath(), defaultValue.pda]); + if (pdaNode) { + isLinked = true; + linkedAccountName = pdaNode.name; + linkedImportFrom = getImportFrom(defaultValue.pda); + } + } else if (isNode(defaultValue.pda, 'pdaNode')) { + pdaNode = defaultValue.pda; + } + + if (!pdaNode) { + logWarn( + `[Rust] Could not resolve PDA node for account [${account.name}] ` + + `in instruction [${instructionName}]. The account will be treated as required.`, + ); + continue; + } + + // Resolve programId: check pdaValueNode override, then pdaNode.programId, then default. + let programIdOverride: string | undefined; + if (isNode(defaultValue.programId, 'accountValueNode')) { + programIdOverride = snakeCase(defaultValue.programId.name); + } else if (isNode(defaultValue.programId, 'argumentValueNode')) { + programIdOverride = snakeCase(defaultValue.programId.name); + } else if (pdaNode.programId) { + programIdOverride = `solana_address::address!("${pdaNode.programId}")`; + } + + const programAddressExpr = programIdOverride ?? `crate::${snakeCase(program.name).toUpperCase()}_ID`; + + // Render seeds — bail out entirely if any variable seed value is missing. + const renderedSeeds: RenderedSeed[] = []; + const seedAccountDeps: string[] = []; + let seedsComplete = true; + + if (isNode(defaultValue.programId, 'accountValueNode')) { + seedAccountDeps.push(camelCase(defaultValue.programId.name)); + } + + for (const seed of pdaNode.seeds) { + if (isNode(seed, 'constantPdaSeedNode')) { + if (isNode(seed.value, 'programIdValueNode')) { + renderedSeeds.push({ + kind: 'programId', + render: `${programAddressExpr}.as_ref()`, + }); + } else { + const valueManifest = renderValueNode(seed.value, getImportFrom, true); + imports.mergeWith(valueManifest.imports); + const rendered = valueManifest.render; + const suffix = rendered.startsWith('[') ? '' : '.as_bytes()'; + const prefix = rendered.startsWith('[') ? '&' : ''; + renderedSeeds.push({ + kind: 'constant', + render: `${prefix}${rendered}${suffix}`, + }); + } + } else if (isNode(seed, 'variablePdaSeedNode')) { + const seedValue = defaultValue.seeds.find(s => s.name === seed.name)?.value; + + if (!seedValue) { + logWarn( + `[Rust] Missing seed value for variable seed [${seed.name}] ` + + `in PDA default for account [${account.name}] ` + + `of instruction [${instructionName}]. Skipping PDA resolution.`, + ); + seedsComplete = false; + break; + } + + const resolvedType = resolveNestedTypeNode(seed.type); + if (isNode(seedValue, 'accountValueNode')) { + const refName = snakeCase(seedValue.name); + seedAccountDeps.push(camelCase(seedValue.name)); + if (resolvedType.kind === 'publicKeyTypeNode') { + renderedSeeds.push({ + kind: 'accountRef', + rawName: refName, + render: `${refName}.as_ref()`, + }); + } else if (resolvedType.kind === 'bytesTypeNode') { + renderedSeeds.push({ + kind: 'accountRef', + rawName: refName, + render: `&${refName}`, + }); + } else { + renderedSeeds.push({ + kind: 'accountRef', + rawName: refName, + render: `${refName}.to_string().as_ref()`, + }); + } + } else if (isNode(seedValue, 'argumentValueNode')) { + const refName = snakeCase(seedValue.name); + if (resolvedType.kind === 'publicKeyTypeNode') { + renderedSeeds.push({ + kind: 'argumentRef', + render: `self.${refName}.as_ref().expect("${refName} is not set").as_ref()`, + }); + } else { + renderedSeeds.push({ + kind: 'argumentRef', + render: `self.${refName}.as_ref().expect("${refName} is not set").to_string().as_ref()`, + }); + } + } else { + const valueManifest = renderValueNode(seedValue, getImportFrom, true); + imports.mergeWith(valueManifest.imports); + if (resolvedType.kind === 'publicKeyTypeNode') { + renderedSeeds.push({ + kind: 'value', + render: `${valueManifest.render}.as_ref()`, + }); + } else { + renderedSeeds.push({ + kind: 'value', + render: `${valueManifest.render}.as_bytes()`, + }); + } + } + } + } + + if (!seedsComplete) continue; + + if (isLinked && linkedImportFrom && linkedAccountName) { + imports.add(`${linkedImportFrom}::${pascalCase(linkedAccountName)}`); + } + + resolvedPdaAccounts[camelCase(account.name)] = { + accountDeps: seedAccountDeps, + isLinked, + linkedAccountName, + linkedImportFrom, + programAddressExpr, + renderedSeeds, + }; + } + + // Build dependency graph and topologically sort accounts. + const accountDeps = new Map>(); + for (const account of accounts) { + const name = camelCase(account.name); + accountDeps.set(name, new Set()); + const pdaInfo = resolvedPdaAccounts[name]; + if (pdaInfo) { + for (const dep of pdaInfo.accountDeps) { + accountDeps.get(name)!.add(dep); + } + } + } + + const sortedAccountNames: string[] = []; + const visited = new Set(); + const visiting = new Set(); + + const topoSort = (name: string): boolean => { + if (visited.has(name)) return true; + if (visiting.has(name)) { + logWarn( + `[Rust] Circular PDA dependency detected for account [${name}] ` + + `in instruction [${instructionName}]. Falling back to required account.`, + ); + delete resolvedPdaAccounts[name]; + return false; + } + visiting.add(name); + const deps = accountDeps.get(name) ?? new Set(); + for (const dep of deps) { + if (accountDeps.has(dep) && !topoSort(dep)) { + // Dependency lost its PDA resolution — remove ours too. + delete resolvedPdaAccounts[name]; + } + } + visiting.delete(name); + visited.add(name); + sortedAccountNames.push(name); + return resolvedPdaAccounts[name] !== undefined || !accountDeps.get(name)?.size; + }; + + for (const account of accounts) { + topoSort(camelCase(account.name)); + } + + return sortedAccountNames.map(name => { + const account = accounts.find(a => camelCase(a.name) === name)!; + return { + ...account, + pdaDefault: resolvedPdaAccounts[name] ?? null, + }; + }); +} + function getConflictsForInstructionAccountsAndArgs(instruction: InstructionNode): string[] { const allNames = [ ...instruction.accounts.map(account => account.name), diff --git a/test/instructionsPage.test.ts b/test/instructionsPage.test.ts index 8d1ad3a..9067e1f 100644 --- a/test/instructionsPage.test.ts +++ b/test/instructionsPage.test.ts @@ -1,10 +1,29 @@ -import { instructionArgumentNode, instructionNode, programNode, stringTypeNode } from '@codama/nodes'; +import { + accountNode, + accountValueNode, + argumentValueNode, + bytesTypeNode, + constantPdaSeedNodeFromProgramId, + constantPdaSeedNodeFromString, + instructionAccountNode, + instructionArgumentNode, + instructionNode, + numberTypeNode, + pdaLinkNode, + pdaNode, + pdaSeedValueNode, + pdaValueNode, + programNode, + publicKeyTypeNode, + stringTypeNode, + variablePdaSeedNode, +} from '@codama/nodes'; import { getFromRenderMap } from '@codama/renderers-core'; import { visit } from '@codama/visitors-core'; -import { test } from 'vitest'; +import { expect, test } from 'vitest'; import { getRenderMapVisitor } from '../src'; -import { codeContains } from './_setup'; +import { codeContains, codeDoesNotContains } from './_setup'; test('it renders a public instruction data struct', () => { // Given the following program with 1 instruction. @@ -69,3 +88,482 @@ test('it renders a default impl for instruction data struct', () => { `fn default(`, ]); }); + +test('it resolves inline pdaValueNode defaults with constant seeds', () => { + // Given an instruction with an account that defaults to an inline PDA with constant seeds. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'eventAuthority', + seeds: [constantPdaSeedNodeFromString('utf8', '__event_authority')], + }), + ), + isSigner: false, + isWritable: false, + name: 'eventAuthority', + }), + ], + name: 'emitEvent', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + // When we render it. + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/emit_event.rs').content; + + // Then the builder resolves the PDA using find_program_address. + codeContains(content, [ + 'unwrap_or_else', + 'find_program_address', + '"__event_authority".as_bytes()', + 'MY_PROGRAM_ID', + ]); +}); + +test('it resolves linked pdaValueNode defaults', () => { + // Given an instruction with an account that defaults to a linked PDA. + const node = programNode({ + accounts: [ + accountNode({ + name: 'testAccount', + pda: pdaLinkNode('testPda'), + }), + ], + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode('testPda'), + isSigner: false, + isWritable: false, + name: 'testAccount', + }), + ], + name: 'doSomething', + }), + ], + name: 'myProgram', + pdas: [pdaNode({ name: 'testPda', seeds: [constantPdaSeedNodeFromString('utf8', 'seed')] })], + publicKey: '1111111111111111111111111111111111111111111', + }); + + // When we render it. + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/do_something.rs').content; + + // Then the builder calls the linked account's find_pda method. + codeContains(content, ['unwrap_or_else', 'TestPda::find_pda']); +}); + +test('it resolves pdaValueNode defaults with variable seeds referencing accounts', () => { + // Given an instruction with accounts where a PDA seed references another account. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + isSigner: false, + isWritable: false, + name: 'owner', + }), + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'myPda', + seeds: [ + constantPdaSeedNodeFromString('utf8', 'token'), + variablePdaSeedNode('owner', publicKeyTypeNode()), + ], + }), + [pdaSeedValueNode('owner', accountValueNode('owner'))], + ), + isSigner: false, + isWritable: false, + name: 'tokenAccount', + }), + ], + name: 'transfer', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + // When we render it. + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/transfer.rs').content; + + // Then the builder resolves the PDA with the owner account as a seed. + codeContains(content, ['find_program_address', '"token".as_bytes()', 'owner.as_ref()']); +}); + +test('it orders accounts by dependency for PDA resolution', () => { + // Given an instruction where the PDA account depends on another account. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + // PDA account listed BEFORE its dependency. + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'derivedPda', + seeds: [variablePdaSeedNode('base', publicKeyTypeNode())], + }), + [pdaSeedValueNode('base', accountValueNode('base'))], + ), + isSigner: false, + isWritable: false, + name: 'derived', + }), + instructionAccountNode({ + isSigner: false, + isWritable: false, + name: 'base', + }), + ], + name: 'init', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + // When we render it. + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/init.rs').content; + + // Then the base account is resolved before the derived PDA account. + const baseIdx = content.indexOf('let base ='); + const derivedIdx = content.indexOf('let derived ='); + codeContains(content, ['let base =', 'let derived =']); + expect(baseIdx).toBeLessThan(derivedIdx); +}); + +test('it marks pdaValueNode accounts as optional in docblock', () => { + // Given an instruction with a PDA-defaulted account. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'authority', + seeds: [constantPdaSeedNodeFromString('utf8', 'auth')], + }), + ), + isSigner: false, + isWritable: false, + name: 'authority', + }), + ], + name: 'doStuff', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + // When we render it. + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/do_stuff.rs').content; + + // Then the docblock marks it as optional with PDA default. + codeContains(content, ['optional', 'default to PDA']); +}); + +test('it resolves pdaValueNode defaults with argument seeds', () => { + // Given a PDA with a variable seed referencing an instruction argument. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'lookup', + seeds: [ + constantPdaSeedNodeFromString('utf8', 'lookup'), + variablePdaSeedNode('id', numberTypeNode('u64')), + ], + }), + [pdaSeedValueNode('id', argumentValueNode('lookupId'))], + ), + isSigner: false, + isWritable: false, + name: 'lookupAccount', + }), + ], + arguments: [ + instructionArgumentNode({ + name: 'lookupId', + type: numberTypeNode('u64'), + }), + ], + name: 'lookup', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/lookup.rs').content; + + // Then the builder uses the argument value as a seed. + codeContains(content, [ + 'unwrap_or_else', + 'find_program_address', + '"lookup".as_bytes()', + 'self.lookup_id.as_ref().expect("lookup_id is not set")', + ]); +}); + +test('it uses pdaNode.programId for inline PDAs with custom program', () => { + // Given an inline PDA with a hardcoded programId. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'crossPda', + programId: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + seeds: [constantPdaSeedNodeFromString('utf8', 'cross')], + }), + ), + isSigner: false, + isWritable: false, + name: 'crossAccount', + }), + ], + name: 'crossProgram', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/cross_program.rs').content; + + // Then it uses the PDA's custom program address, not the current program. + codeContains(content, ['find_program_address', 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL']); + // The PDA derivation should NOT use the current program's ID. + codeDoesNotContains(content, ['&crate::MY_PROGRAM_ID']); +}); + +test('it renders programIdValueNode constant seeds', () => { + // Given a PDA with a programIdValueNode seed (program address used as seed bytes). + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'programData', + seeds: [ + constantPdaSeedNodeFromString('utf8', 'data'), + constantPdaSeedNodeFromProgramId(), + ], + }), + ), + isSigner: false, + isWritable: false, + name: 'programData', + }), + ], + name: 'readData', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/read_data.rs').content; + + // Then the programId seed renders using the program constant. + codeContains(content, ['find_program_address', '"data".as_bytes()', 'MY_PROGRAM_ID.as_ref()']); +}); + +test('it renders bytesTypeNode variable seeds with & prefix', () => { + // Given a PDA with a variable seed of bytesTypeNode. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + isSigner: false, + isWritable: false, + name: 'data', + }), + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'derived', + seeds: [variablePdaSeedNode('rawData', bytesTypeNode())], + }), + [pdaSeedValueNode('rawData', accountValueNode('data'))], + ), + isSigner: false, + isWritable: false, + name: 'derived', + }), + ], + name: 'process', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/process.rs').content; + + // Then the bytes seed uses & prefix, not .as_ref(). + codeContains(content, ['&data,']); + codeDoesNotContains(content, ['data.as_ref()']); +}); + +test('it resolves linked pdaValueNode with variable account seeds', () => { + // Given a linked PDA whose find_pda takes an account reference. + const node = programNode({ + accounts: [ + accountNode({ + name: 'userToken', + pda: pdaLinkNode('userTokenPda'), + }), + ], + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + isSigner: false, + isWritable: false, + name: 'owner', + }), + instructionAccountNode({ + defaultValue: pdaValueNode('userTokenPda', [ + pdaSeedValueNode('owner', accountValueNode('owner')), + ]), + isSigner: false, + isWritable: false, + name: 'userToken', + }), + ], + name: 'claim', + }), + ], + name: 'myProgram', + pdas: [ + pdaNode({ + name: 'userTokenPda', + seeds: [ + constantPdaSeedNodeFromString('utf8', 'token'), + variablePdaSeedNode('owner', publicKeyTypeNode()), + ], + }), + ], + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/claim.rs').content; + + // Then the linked find_pda receives the account reference using rawName. + codeContains(content, ['UserTokenPda::find_pda', '&owner,']); +}); + +test('it falls back to .expect() on circular PDA dependencies', () => { + // Given two accounts with circular PDA dependencies (A depends on B, B depends on A). + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'pdaA', + seeds: [variablePdaSeedNode('b', publicKeyTypeNode())], + }), + [pdaSeedValueNode('b', accountValueNode('accountB'))], + ), + isSigner: false, + isWritable: false, + name: 'accountA', + }), + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'pdaB', + seeds: [variablePdaSeedNode('a', publicKeyTypeNode())], + }), + [pdaSeedValueNode('a', accountValueNode('accountA'))], + ), + isSigner: false, + isWritable: false, + name: 'accountB', + }), + ], + name: 'circular', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/circular.rs').content; + + // Then both accounts fall back to .expect() since neither PDA can be resolved. + codeContains(content, [ + 'account_a = self.account_a.expect("account_a is not set")', + 'account_b = self.account_b.expect("account_b is not set")', + ]); + codeDoesNotContains(content, ['find_program_address', 'unwrap_or_else']); +}); + +test('it uses optional path (not PDA) when account is isOptional with PDA default', () => { + // Given an account that is both isOptional and has a PDA default. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode( + pdaNode({ + name: 'optPda', + seeds: [constantPdaSeedNodeFromString('utf8', 'opt')], + }), + ), + isOptional: true, + isSigner: false, + isWritable: false, + name: 'optionalAccount', + }), + ], + name: 'maybeUse', + }), + ], + name: 'myProgram', + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/maybe_use.rs').content; + + // Then the optional flag takes precedence — no PDA auto-derivation. + codeContains(content, ['let optional_account = self.optional_account;']); + codeDoesNotContains(content, ['find_program_address', 'unwrap_or_else']); +}); From 995e2ccc5866d70644044adc0ad5d64b87499227 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:13:30 -0700 Subject: [PATCH 2/2] fix: use account name (not PDA name) for linked find_pda calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a pdaLinkNode resolves to a PDA, use the corresponding account struct's name for the find_pda() call — since find_pda is generated on account structs, not PDA nodes. Falls back to inline find_program_address when no account references the PDA. Fixes broken codegen when PDA name differs from account name (e.g. "extensions" PDA vs "escrowExtensionsHeader" account) and when a PDA has no corresponding account struct at all. --- src/getRenderMapVisitor.ts | 13 ++++-- test/instructionsPage.test.ts | 87 +++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/getRenderMapVisitor.ts b/src/getRenderMapVisitor.ts index def0995..708f17a 100644 --- a/src/getRenderMapVisitor.ts +++ b/src/getRenderMapVisitor.ts @@ -400,9 +400,16 @@ function resolveInstructionPdaDefaults(ctx: { if (isNode(defaultValue.pda, 'pdaLinkNode')) { pdaNode = linkables.get([...stack.getPath(), defaultValue.pda]); if (pdaNode) { - isLinked = true; - linkedAccountName = pdaNode.name; - linkedImportFrom = getImportFrom(defaultValue.pda); + // Only use the linked find_pda() path if there's an account struct + // that references this PDA (since find_pda is generated on account structs). + const linkedAccount = program.accounts.find( + a => a.pda && isNode(a.pda, 'pdaLinkNode') && a.pda.name === defaultValue.pda.name, + ); + if (linkedAccount) { + isLinked = true; + linkedAccountName = linkedAccount.name; + linkedImportFrom = getImportFrom(defaultValue.pda); + } } } else if (isNode(defaultValue.pda, 'pdaNode')) { pdaNode = defaultValue.pda; diff --git a/test/instructionsPage.test.ts b/test/instructionsPage.test.ts index 9067e1f..61360d6 100644 --- a/test/instructionsPage.test.ts +++ b/test/instructionsPage.test.ts @@ -158,8 +158,8 @@ test('it resolves linked pdaValueNode defaults', () => { const renderMap = visit(node, getRenderMapVisitor()); const content = getFromRenderMap(renderMap, 'instructions/do_something.rs').content; - // Then the builder calls the linked account's find_pda method. - codeContains(content, ['unwrap_or_else', 'TestPda::find_pda']); + // Then the builder calls the account's find_pda method (using the account name, not PDA name). + codeContains(content, ['unwrap_or_else', 'TestAccount::find_pda']); }); test('it resolves pdaValueNode defaults with variable seeds referencing accounts', () => { @@ -481,8 +481,8 @@ test('it resolves linked pdaValueNode with variable account seeds', () => { const renderMap = visit(node, getRenderMapVisitor()); const content = getFromRenderMap(renderMap, 'instructions/claim.rs').content; - // Then the linked find_pda receives the account reference using rawName. - codeContains(content, ['UserTokenPda::find_pda', '&owner,']); + // Then the linked find_pda uses the account name (not PDA name) and receives the account reference. + codeContains(content, ['UserToken::find_pda', '&owner,']); }); test('it falls back to .expect() on circular PDA dependencies', () => { @@ -567,3 +567,82 @@ test('it uses optional path (not PDA) when account is isOptional with PDA defaul codeContains(content, ['let optional_account = self.optional_account;']); codeDoesNotContains(content, ['find_program_address', 'unwrap_or_else']); }); + +test('it inlines find_program_address for linked PDA without matching account struct', () => { + // Given a PDA defined in program.pdas but with NO corresponding account in program.accounts. + const node = programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode('pdaOnly', []), + isSigner: false, + isWritable: false, + name: 'derivedAccount', + }), + ], + name: 'useIt', + }), + ], + name: 'myProgram', + pdas: [pdaNode({ name: 'pdaOnly', seeds: [constantPdaSeedNodeFromString('utf8', 'pda_only')] })], + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/use_it.rs').content; + + // Then it falls back to inline find_program_address (not find_pda). + codeContains(content, ['find_program_address', '"pda_only".as_bytes()']); + codeDoesNotContains(content, ['::find_pda']); +}); + +test('it uses the account name (not PDA name) for linked find_pda when names differ', () => { + // Given an account named differently from its PDA. + const node = programNode({ + accounts: [ + accountNode({ + name: 'extensionsHeader', + pda: pdaLinkNode('extensions'), + }), + ], + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + isSigner: false, + isWritable: false, + name: 'owner', + }), + instructionAccountNode({ + defaultValue: pdaValueNode('extensions', [ + pdaSeedValueNode('owner', accountValueNode('owner')), + ]), + isSigner: false, + isWritable: false, + name: 'ext', + }), + ], + name: 'readExtensions', + }), + ], + name: 'myProgram', + pdas: [ + pdaNode({ + name: 'extensions', + seeds: [ + constantPdaSeedNodeFromString('utf8', 'ext'), + variablePdaSeedNode('owner', publicKeyTypeNode()), + ], + }), + ], + publicKey: '1111111111111111111111111111111111111111111', + }); + + const renderMap = visit(node, getRenderMapVisitor()); + const content = getFromRenderMap(renderMap, 'instructions/read_extensions.rs').content; + + // Then it calls ExtensionsHeader::find_pda (the account name), not Extensions::find_pda. + codeContains(content, ['ExtensionsHeader::find_pda']); + codeDoesNotContains(content, ['Extensions::find_pda']); +});