diff --git a/.github/workflows/reusable-tests.yaml b/.github/workflows/reusable-tests.yaml index f7c08e2948..4fddb9243a 100644 --- a/.github/workflows/reusable-tests.yaml +++ b/.github/workflows/reusable-tests.yaml @@ -418,6 +418,8 @@ jobs: path: tests/custom-program - cmd: cd tests/typescript && anchor test --skip-lint && npx tsc --noEmit path: tests/typescript + - cmd: cd tests/duplicate-mutable-accounts && anchor test --skip-lint + path: tests/duplicate-mutable-accounts # zero-copy tests cause `/usr/bin/ld: final link failed: No space left on device` # on GitHub runners. It is likely caused by `cargo test-sbf` since all other tests # don't have this problem. diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c9cfc93d..fa1a9bd437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The minor version will be incremented upon a breaking change and the patch versi ### Breaking +- lang: Disallow duplicate mutable accounts by default. But allows duplicate mutable accounts in instruction contexts using `dup` constraint ([#3946](https://github.com/solana-foundation/anchor/pull/3946)). + ## [0.32.1] - 2025-10-09 ### Features diff --git a/bench/BINARY_SIZE.md b/bench/BINARY_SIZE.md index 5ea8ca8681..320b5ad865 100644 --- a/bench/BINARY_SIZE.md +++ b/bench/BINARY_SIZE.md @@ -16,9 +16,9 @@ The programs and their tests are located in [/tests/bench](https://github.com/co Solana version: 2.3.0 -| Program | Binary Size | - | -| ------- | ----------- | ------------------------ | -| bench | 932,424 | 🟢 **-194,416 (17.25%)** | +| Program | Binary Size | - | +| ------- | ----------- | ----------------------- | +| bench | 1,015,624 | 🟢 **-111,216 (9.87%)** | ### Notable changes diff --git a/bench/COMPUTE_UNITS.md b/bench/COMPUTE_UNITS.md index 5d3134b461..00bf4fb2b3 100644 --- a/bench/COMPUTE_UNITS.md +++ b/bench/COMPUTE_UNITS.md @@ -18,93 +18,93 @@ Solana version: 2.3.0 | Instruction | Compute Units | - | | --------------------------- | ------------- | --------------------- | -| accountInfo1 | 702 | 🔴 **+17 (2.48%)** | -| accountInfo2 | 1,124 | 🔴 **+71 (6.74%)** | -| accountInfo4 | 1,921 | 🔴 **+171 (9.77%)** | -| accountInfo8 | 3,480 | 🔴 **+345 (11.00%)** | +| accountInfo1 | 760 | 🔴 **+75 (10.95%)** | +| accountInfo2 | 1,180 | 🔴 **+127 (12.06%)** | +| accountInfo4 | 1,952 | 🔴 **+202 (11.54%)** | +| accountInfo8 | 3,530 | 🔴 **+395 (12.60%)** | | accountEmptyInit1 | 4,770 | 🟢 **-145 (2.95%)** | -| accountEmpty1 | 738 | 🟢 **-36 (4.65%)** | +| accountEmpty1 | 773 | 🟢 **-1 (0.13%)** | | accountEmptyInit2 | 8,487 | 🟢 **-306 (3.48%)** | -| accountEmpty2 | 1,138 | 🟢 **-36 (3.07%)** | +| accountEmpty2 | 1,173 | 🟢 **-1 (0.09%)** | | accountEmptyInit4 | 15,915 | 🟢 **-833 (4.97%)** | -| accountEmpty4 | 1,923 | 🟢 **-39 (1.99%)** | +| accountEmpty4 | 1,960 | 🟢 **-2 (0.10%)** | | accountEmptyInit8 | 30,779 | 🟢 **-1,578 (4.88%)** | -| accountEmpty8 | 3,500 | 🟢 **-48 (1.35%)** | +| accountEmpty8 | 3,539 | 🟢 **-9 (0.25%)** | | accountSizedInit1 | 4,864 | 🟢 **-155 (3.09%)** | -| accountSized1 | 786 | 🟢 **-36 (4.38%)** | +| accountSized1 | 820 | 🟢 **-2 (0.24%)** | | accountSizedInit2 | 8,654 | 🟢 **-327 (3.64%)** | -| accountSized2 | 1,198 | 🟢 **-42 (3.39%)** | +| accountSized2 | 1,236 | 🟢 **-4 (0.32%)** | | accountSizedInit4 | 16,236 | 🟢 **-918 (5.35%)** | -| accountSized4 | 2,031 | 🟢 **-51 (2.45%)** | +| accountSized4 | 2,070 | 🟢 **-12 (0.58%)** | | accountSizedInit8 | 31,363 | 🟢 **-1,650 (5.00%)** | -| accountSized8 | 3,694 | 🟢 **-68 (1.81%)** | +| accountSized8 | 3,731 | 🟢 **-31 (0.82%)** | | accountUnsizedInit1 | 4,967 | 🟢 **-160 (3.12%)** | -| accountUnsized1 | 814 | 🟢 **-60 (6.86%)** | +| accountUnsized1 | 854 | 🟢 **-20 (2.29%)** | | accountUnsizedInit2 | 8,841 | 🟢 **-410 (4.43%)** | -| accountUnsized2 | 1,240 | 🟢 **-86 (6.49%)** | +| accountUnsized2 | 1,281 | 🟢 **-45 (3.39%)** | | accountUnsizedInit4 | 16,559 | 🟢 **-819 (4.71%)** | -| accountUnsized4 | 2,093 | 🟢 **-138 (6.19%)** | +| accountUnsized4 | 2,134 | 🟢 **-97 (4.35%)** | | accountUnsizedInit8 | 31,985 | 🟢 **-1,976 (5.82%)** | -| accountUnsized8 | 3,797 | 🟢 **-238 (5.90%)** | +| accountUnsized8 | 3,837 | 🟢 **-198 (4.91%)** | | boxedAccountEmptyInit1 | 4,864 | 🟢 **-143 (2.86%)** | -| boxedAccountEmpty1 | 831 | 🟢 **-33 (3.82%)** | +| boxedAccountEmpty1 | 864 | - | | boxedAccountEmptyInit2 | 8,604 | 🟢 **-302 (3.39%)** | -| boxedAccountEmpty2 | 1,253 | 🟢 **-33 (2.57%)** | +| boxedAccountEmpty2 | 1,285 | 🟢 **-1 (0.08%)** | | boxedAccountEmptyInit4 | 16,075 | 🟢 **-827 (4.89%)** | -| boxedAccountEmpty4 | 2,077 | 🟢 **-38 (1.80%)** | +| boxedAccountEmpty4 | 2,112 | 🟢 **-3 (0.14%)** | | boxedAccountEmptyInit8 | 31,026 | 🟢 **-1,565 (4.80%)** | | boxedAccountEmpty8 | 3,797 | 🟢 **-4 (0.11%)** | | boxedAccountSizedInit1 | 4,952 | 🟢 **-151 (2.96%)** | -| boxedAccountSized1 | 877 | 🟢 **-35 (3.84%)** | +| boxedAccountSized1 | 913 | 🔴 **+1 (0.11%)** | | boxedAccountSizedInit2 | 8,756 | 🟢 **-319 (3.52%)** | -| boxedAccountSized2 | 1,318 | 🟢 **-37 (2.73%)** | +| boxedAccountSized2 | 1,351 | 🟢 **-4 (0.30%)** | | boxedAccountSizedInit4 | 16,355 | 🟢 **-859 (4.99%)** | -| boxedAccountSized4 | 2,185 | 🟢 **-46 (2.06%)** | +| boxedAccountSized4 | 2,218 | 🟢 **-13 (0.58%)** | | boxedAccountSizedInit8 | 31,562 | 🟢 **-1,959 (5.84%)** | | boxedAccountSized8 | 3,984 | 🟢 **-23 (0.57%)** | | boxedAccountUnsizedInit1 | 5,044 | 🟢 **-158 (3.04%)** | -| boxedAccountUnsized1 | 907 | 🟢 **-57 (5.91%)** | +| boxedAccountUnsized1 | 943 | 🟢 **-21 (2.18%)** | | boxedAccountUnsizedInit2 | 8,916 | 🟢 **-335 (3.62%)** | -| boxedAccountUnsized2 | 1,352 | 🟢 **-82 (5.72%)** | +| boxedAccountUnsized2 | 1,386 | 🟢 **-48 (3.35%)** | | boxedAccountUnsizedInit4 | 16,651 | 🟢 **-891 (5.08%)** | -| boxedAccountUnsized4 | 2,234 | 🟢 **-133 (5.62%)** | +| boxedAccountUnsized4 | 2,270 | 🟢 **-97 (4.10%)** | | boxedAccountUnsizedInit8 | 32,130 | 🟢 **-2,023 (5.92%)** | | boxedAccountUnsized8 | 4,063 | 🟢 **-194 (4.56%)** | -| boxedInterfaceAccountMint1 | 1,092 | 🟢 **-18 (1.62%)** | -| boxedInterfaceAccountMint2 | 1,490 | 🟢 **-44 (2.87%)** | -| boxedInterfaceAccountMint4 | 2,276 | 🟢 **-94 (3.97%)** | +| boxedInterfaceAccountMint1 | 1,128 | 🔴 **+18 (1.62%)** | +| boxedInterfaceAccountMint2 | 1,523 | 🟢 **-11 (0.72%)** | +| boxedInterfaceAccountMint4 | 2,307 | 🟢 **-63 (2.66%)** | | boxedInterfaceAccountMint8 | 3,907 | 🟢 **-157 (3.86%)** | -| boxedInterfaceAccountToken1 | 1,219 | 🟢 **-27 (2.17%)** | -| boxedInterfaceAccountToken2 | 1,732 | 🟢 **-62 (3.46%)** | -| boxedInterfaceAccountToken4 | 2,748 | 🟢 **-130 (4.52%)** | +| boxedInterfaceAccountToken1 | 1,255 | 🔴 **+9 (0.72%)** | +| boxedInterfaceAccountToken2 | 1,765 | 🟢 **-29 (1.62%)** | +| boxedInterfaceAccountToken4 | 2,779 | 🟢 **-99 (3.44%)** | | boxedInterfaceAccountToken8 | 4,839 | 🟢 **-229 (4.52%)** | -| interfaceAccountMint1 | 1,107 | 🟢 **-19 (1.69%)** | -| interfaceAccountMint2 | 1,494 | 🟢 **-68 (4.35%)** | -| interfaceAccountMint4 | 2,276 | 🟢 **-156 (6.41%)** | +| interfaceAccountMint1 | 1,145 | 🔴 **+19 (1.69%)** | +| interfaceAccountMint2 | 1,533 | 🟢 **-29 (1.86%)** | +| interfaceAccountMint4 | 2,313 | 🟢 **-119 (4.89%)** | | interfaceAccountMint8 | 3,835 | 🟢 **-328 (7.88%)** | -| interfaceAccountToken1 | 1,239 | 🟢 **-29 (2.29%)** | -| interfaceAccountToken2 | 1,753 | 🟢 **-96 (5.19%)** | -| interfaceAccountToken4 | 2,783 | 🟢 **-214 (7.14%)** | -| interface1 | 883 | 🔴 **+5 (0.57%)** | -| interface2 | 1,029 | 🔴 **+6 (0.59%)** | -| interface4 | 1,308 | 🔴 **+7 (0.54%)** | -| interface8 | 1,887 | 🔴 **+20 (1.07%)** | -| program1 | 899 | 🔴 **+9 (1.01%)** | -| program2 | 1,049 | 🔴 **+14 (1.35%)** | -| program4 | 1,336 | 🔴 **+23 (1.75%)** | -| program8 | 1,917 | 🔴 **+38 (2.02%)** | -| signer1 | 888 | 🔴 **+14 (1.60%)** | -| signer2 | 1,244 | 🔴 **+71 (6.05%)** | -| signer4 | 1,904 | 🔴 **+145 (8.24%)** | -| signer8 | 3,293 | 🔴 **+352 (11.97%)** | -| systemAccount1 | 910 | 🟢 **-1 (0.11%)** | -| systemAccount2 | 1,269 | 🔴 **+34 (2.75%)** | -| systemAccount4 | 1,972 | 🔴 **+101 (5.40%)** | -| systemAccount8 | 3,385 | 🔴 **+232 (7.36%)** | -| uncheckedAccount1 | 896 | 🔴 **+14 (1.59%)** | -| uncheckedAccount2 | 1,233 | 🔴 **+71 (6.11%)** | -| uncheckedAccount4 | 1,861 | 🔴 **+145 (8.45%)** | -| uncheckedAccount8 | 3,171 | 🔴 **+338 (11.93%)** | +| interfaceAccountToken1 | 1,279 | 🔴 **+11 (0.87%)** | +| interfaceAccountToken2 | 1,792 | 🟢 **-57 (3.08%)** | +| interfaceAccountToken4 | 2,825 | 🟢 **-172 (5.74%)** | +| interface1 | 918 | 🔴 **+40 (4.56%)** | +| interface2 | 1,064 | 🔴 **+41 (4.01%)** | +| interface4 | 1,345 | 🔴 **+44 (3.38%)** | +| interface8 | 1,912 | 🔴 **+45 (2.41%)** | +| program1 | 934 | 🔴 **+44 (4.94%)** | +| program2 | 1,084 | 🔴 **+49 (4.73%)** | +| program4 | 1,373 | 🔴 **+60 (4.57%)** | +| program8 | 1,956 | 🔴 **+77 (4.10%)** | +| signer1 | 923 | 🔴 **+49 (5.61%)** | +| signer2 | 1,272 | 🔴 **+99 (8.44%)** | +| signer4 | 1,957 | 🔴 **+198 (11.26%)** | +| signer8 | 3,332 | 🔴 **+391 (13.29%)** | +| systemAccount1 | 945 | 🔴 **+34 (3.73%)** | +| systemAccount2 | 1,304 | 🔴 **+69 (5.59%)** | +| systemAccount4 | 2,009 | 🔴 **+138 (7.38%)** | +| systemAccount8 | 3,424 | 🔴 **+271 (8.59%)** | +| uncheckedAccount1 | 931 | 🔴 **+49 (5.56%)** | +| uncheckedAccount2 | 1,263 | 🔴 **+101 (8.69%)** | +| uncheckedAccount4 | 1,911 | 🔴 **+195 (11.36%)** | +| uncheckedAccount8 | 3,207 | 🔴 **+374 (13.20%)** | ### Notable changes diff --git a/bench/STACK_MEMORY.md b/bench/STACK_MEMORY.md index 96acf23cce..1ec4c55dcb 100644 --- a/bench/STACK_MEMORY.md +++ b/bench/STACK_MEMORY.md @@ -16,8 +16,95 @@ The programs and their tests are located in [/tests/bench](https://github.com/co Solana version: 2.3.0 -| Instruction | Stack Memory | - | -| ----------- | ------------ | --- | +| Instruction | Stack Memory | - | +| ------------------------------ | ------------ | ------------------- | +| account_info1 | 46 | - | +| account_info2 | 88 | - | +| account_info4 | 88 | - | +| account_info8 | 88 | - | +| account_empty_init1 | 88 | - | +| account_empty_init2 | 88 | - | +| account_empty_init4 | 88 | - | +| account_empty_init8 | 88 | - | +| account_empty1 | 88 | - | +| account_empty2 | 88 | - | +| account_empty4 | 88 | - | +| account_empty8 | 88 | - | +| account_sized_init1 | 88 | - | +| account_sized_init2 | 88 | - | +| account_sized_init4 | 88 | - | +| account_sized_init8 | 88 | - | +| account_sized1 | 88 | - | +| account_sized2 | 88 | - | +| account_sized4 | 88 | - | +| account_sized8 | 88 | - | +| account_unsized_init1 | 88 | - | +| account_unsized_init2 | 88 | - | +| account_unsized_init4 | 88 | - | +| account_unsized_init8 | 88 | - | +| account_unsized1 | 88 | - | +| account_unsized2 | 88 | - | +| account_unsized4 | 88 | - | +| account_unsized8 | 88 | - | +| boxed_account_empty_init1 | 88 | - | +| boxed_account_empty_init2 | 88 | - | +| boxed_account_empty_init4 | 88 | - | +| boxed_account_empty_init8 | 88 | - | +| boxed_account_empty1 | 88 | - | +| boxed_account_empty2 | 88 | - | +| boxed_account_empty4 | 88 | - | +| boxed_account_empty8 | 96 | 🔴 **+8 (9.09%)** | +| boxed_account_sized_init1 | 88 | - | +| boxed_account_sized_init2 | 88 | - | +| boxed_account_sized_init4 | 88 | - | +| boxed_account_sized_init8 | 88 | - | +| boxed_account_sized1 | 88 | - | +| boxed_account_sized2 | 88 | - | +| boxed_account_sized4 | 88 | - | +| boxed_account_sized8 | 96 | 🔴 **+8 (9.09%)** | +| boxed_account_unsized_init1 | 88 | - | +| boxed_account_unsized_init2 | 88 | - | +| boxed_account_unsized_init4 | 88 | - | +| boxed_account_unsized_init8 | 88 | - | +| boxed_account_unsized1 | 88 | - | +| boxed_account_unsized2 | 88 | - | +| boxed_account_unsized4 | 88 | - | +| boxed_account_unsized8 | 96 | 🔴 **+8 (9.09%)** | +| boxed_interface_account_mint1 | 88 | - | +| boxed_interface_account_mint2 | 88 | - | +| boxed_interface_account_mint4 | 88 | - | +| boxed_interface_account_mint8 | 96 | 🔴 **+8 (9.09%)** | +| boxed_interface_account_token1 | 88 | - | +| boxed_interface_account_token2 | 88 | - | +| boxed_interface_account_token4 | 88 | - | +| boxed_interface_account_token8 | 96 | 🔴 **+8 (9.09%)** | +| interface_account_mint1 | 88 | - | +| interface_account_mint2 | 88 | - | +| interface_account_mint4 | 88 | - | +| interface_account_mint8 | 88 | - | +| interface_account_token1 | 104 | 🔴 **+24 (30.00%)** | +| interface_account_token2 | 104 | 🔴 **+24 (30.00%)** | +| interface_account_token4 | 104 | 🔴 **+24 (30.00%)** | +| interface1 | 88 | - | +| interface2 | 88 | - | +| interface4 | 88 | - | +| interface8 | 88 | - | +| program1 | 88 | - | +| program2 | 88 | - | +| program4 | 88 | - | +| program8 | 88 | - | +| signer1 | 88 | - | +| signer2 | 88 | - | +| signer4 | 88 | - | +| signer8 | 88 | - | +| system_account1 | 88 | - | +| system_account2 | 88 | - | +| system_account4 | 88 | - | +| system_account8 | 88 | - | +| unchecked_account1 | 88 | - | +| unchecked_account2 | 88 | - | +| unchecked_account4 | 88 | - | +| unchecked_account8 | 88 | - | ### Notable changes diff --git a/docs/content/docs/references/account-constraints.mdx b/docs/content/docs/references/account-constraints.mdx index 5f12ca4b9b..ee8890240f 100644 --- a/docs/content/docs/references/account-constraints.mdx +++ b/docs/content/docs/references/account-constraints.mdx @@ -38,6 +38,33 @@ Examples: [Github](https://github.com/solana-developers/anchor-examples/tree/mai #[account(mut @ )] ``` +### `#[account(dup)]` + +Description: By default, Anchor will prevents duplicate mutable accounts to avoid potential security issues and unintended behavior. +The `dup` constraint explicitly allows this for cases where it's intentional and safe. + +**Note**: This constraint only applies to mutable accounts (`mut`). Readonly accounts naturally allow duplicates without requiring the `dup` constraint. + +```rust title="attribute" +#[account(mut, dup)] +#[account(mut, dup @ )] +``` + +```rust title="snippet" +#[derive(Accounts)] +pub struct AllowsDuplicateMutable<'info> { + #[account(mut)] + pub account1: Account<'info, Counter>, + // This account can be the same as account1 + #[account(mut, dup)] + pub account2: Account<'info, Counter>, +} + +pub fn allows_duplicate_mutable(ctx: Context) -> Result<()> { + Ok(()) +} +``` + ### `#[account(init)]` Description: Creates the account via a CPI to the system program and initializes diff --git a/lang/derive/accounts/src/lib.rs b/lang/derive/accounts/src/lib.rs index 7cfb19479b..1c379b913d 100644 --- a/lang/derive/accounts/src/lib.rs +++ b/lang/derive/accounts/src/lib.rs @@ -90,6 +90,22 @@ use syn::parse_macro_input; /// /// /// +/// #[account(dup)]

+/// +/// +/// Allows the same mutable account to be passed multiple times within the same instruction context.
+/// By default, Anchor will prevents duplicate mutable accounts to avoid potential security issues and unintended behavior.
+/// The dup constraint explicitly allows this for cases where it's intentional and safe.
+/// This constraint only applies to mutable accounts (mut). Readonly accounts naturally allow duplicates without requiring the dup constraint.
+/// Example: +///

+/// #[account(mut)]
+/// pub account1: Account<'info, Counter>,
+///                 
+/// +/// +/// +/// /// #[account(init, payer = <target_account>, space = <num_bytes>)] /// /// diff --git a/lang/src/error.rs b/lang/src/error.rs index a7240d1afb..5149484091 100644 --- a/lang/src/error.rs +++ b/lang/src/error.rs @@ -168,6 +168,9 @@ pub enum ErrorCode { /// 2039 - A transfer hook extension transfer hook program id constraint was violated #[msg("A transfer hook extension transfer hook program id constraint was violated")] ConstraintMintTransferHookExtensionProgramId, + /// 2040 - A duplicate mutable account constraint was violated + #[msg("A duplicate mutable account constraint was violated")] + ConstraintDuplicateMutableAccount, // Signature verification errors /// 2040 - Invalid Ed25519 program id for signature verification diff --git a/lang/syn/src/codegen/accounts/constraints.rs b/lang/syn/src/codegen/accounts/constraints.rs index 2d9116b7f4..ae43de7eca 100644 --- a/lang/syn/src/codegen/accounts/constraints.rs +++ b/lang/syn/src/codegen/accounts/constraints.rs @@ -76,6 +76,7 @@ pub fn linearize(c_group: &ConstraintGroup) -> Vec { init, zeroed, mutable, + dup, signer, has_one, raw, @@ -111,6 +112,9 @@ pub fn linearize(c_group: &ConstraintGroup) -> Vec { if let Some(c) = mutable { constraints.push(Constraint::Mut(c)); } + if let Some(c) = dup { + constraints.push(Constraint::Dup(c)); + } if let Some(c) = signer { constraints.push(Constraint::Signer(c)); } @@ -149,6 +153,7 @@ fn generate_constraint( Constraint::Init(c) => generate_constraint_init(f, c, accs), Constraint::Zeroed(c) => generate_constraint_zeroed(f, c, accs), Constraint::Mut(c) => generate_constraint_mut(f, c), + Constraint::Dup(_) => quote! {}, // No-op: dup is handled by duplicate checking logic Constraint::HasOne(c) => generate_constraint_has_one(f, c, accs), Constraint::Signer(c) => generate_constraint_signer(f, c), Constraint::Raw(c) => generate_constraint_raw(&f.ident, c), diff --git a/lang/syn/src/codegen/accounts/try_accounts.rs b/lang/syn/src/codegen/accounts/try_accounts.rs index 30721f4000..62f413fa15 100644 --- a/lang/syn/src/codegen/accounts/try_accounts.rs +++ b/lang/syn/src/codegen/accounts/try_accounts.rs @@ -270,6 +270,9 @@ pub fn generate_constraints(accs: &AccountsStruct) -> proc_macro2::TokenStream { .map(|f| constraints::generate(f, accs)) .collect(); + // Generate duplicate mutable account validation + let duplicate_checks = generate_duplicate_mutable_checks(accs); + // Constraint checks for each account fields. let access_checks: Vec = non_init_fields .iter() @@ -281,6 +284,7 @@ pub fn generate_constraints(accs: &AccountsStruct) -> proc_macro2::TokenStream { quote! { #(#init_fields)* + #duplicate_checks #(#access_checks)* } } @@ -315,3 +319,94 @@ fn is_init(af: &AccountField) -> bool { AccountField::Field(f) => f.constraints.init.is_some(), } } + +// Generates duplicate mutable account validation logic +fn generate_duplicate_mutable_checks(accs: &AccountsStruct) -> proc_macro2::TokenStream { + // Collect all mutable account fields without `dup` constraint, excluding UncheckedAccount, Signer, and init accounts. + let candidates: Vec<_> = accs + .fields + .iter() + .filter_map(|af| match af { + AccountField::Field(f) + if f.constraints.is_mutable() + && !f.constraints.is_dup() + && f.constraints.init.is_none() => + { + match &f.ty { + crate::Ty::UncheckedAccount => None, // unchecked by design + crate::Ty::Signer => None, // signers are excluded as they're typically payers + _ => Some(f), + } + } + _ => None, + }) + .collect(); + + if candidates.is_empty() { + // No declared mutable accounts, but still need to check remaining_accounts + return quote! { + // Duplicate mutable account validation for remaining_accounts only + { + let mut __mutable_accounts = std::collections::HashSet::new(); + + for __remaining_account in __accounts.iter() { + if __remaining_account.is_writable { + if !__mutable_accounts.insert(*__remaining_account.key) { + return Err(anchor_lang::error::Error::from( + anchor_lang::error::ErrorCode::ConstraintDuplicateMutableAccount + ) + .with_account_name(format!("{} (remaining_accounts)", __remaining_account.key))); + } + } + } + } + }; + } + + let mut field_keys = Vec::with_capacity(candidates.len()); + let mut field_name_strs = Vec::with_capacity(candidates.len()); + + for f in candidates.iter() { + let name = &f.ident; + + if f.is_optional { + field_keys.push(quote! { #name.as_ref().map(|f| f.key()) }); + } else { + field_keys.push(quote! { Some(#name.key()) }); + } + + // Use stringify! to avoid runtime allocation + field_name_strs.push(quote! { stringify!(#name) }); + } + + quote! { + // Duplicate mutable account validation - using HashSet + { + let mut __mutable_accounts = std::collections::HashSet::new(); + + // First, check declared mutable accounts for duplicates among themselves + #( + if let Some(key) = #field_keys { + // Check for duplicates and insert the key and account name + if !__mutable_accounts.insert(key) { + return Err(anchor_lang::error::Error::from( + anchor_lang::error::ErrorCode::ConstraintDuplicateMutableAccount + ).with_account_name(#field_name_strs)); + } + } + )* + + // This prevents duplicates from being passed via remaining_accounts + for __remaining_account in __accounts.iter() { + if __remaining_account.is_writable { + if !__mutable_accounts.insert(*__remaining_account.key) { + return Err(anchor_lang::error::Error::from( + anchor_lang::error::ErrorCode::ConstraintDuplicateMutableAccount + ) + .with_account_name(format!("{} (remaining_accounts)", __remaining_account.key))); + } + } + } + } + } +} diff --git a/lang/syn/src/lib.rs b/lang/syn/src/lib.rs index 446bc5b64e..8c860c0f89 100644 --- a/lang/syn/src/lib.rs +++ b/lang/syn/src/lib.rs @@ -698,6 +698,7 @@ pub struct ConstraintGroup { pub init: Option, pub zeroed: Option, pub mutable: Option, + pub dup: Option, pub signer: Option, pub owner: Option, pub rent_exempt: Option, @@ -722,6 +723,10 @@ impl ConstraintGroup { self.mutable.is_some() } + pub fn is_dup(&self) -> bool { + self.dup.is_some() + } + pub fn is_signer(&self) -> bool { self.signer.is_some() } @@ -740,6 +745,7 @@ pub enum Constraint { Init(ConstraintInitGroup), Zeroed(ConstraintZeroed), Mut(ConstraintMut), + Dup(ConstraintDup), Signer(ConstraintSigner), HasOne(ConstraintHasOne), Raw(ConstraintRaw), @@ -762,6 +768,7 @@ pub enum ConstraintToken { Init(Context), Zeroed(Context), Mut(Context), + Dup(Context), Signer(Context), HasOne(Context), Raw(Context), @@ -827,6 +834,9 @@ pub struct ConstraintMut { pub error: Option, } +#[derive(Debug, Clone)] +pub struct ConstraintDup {} + #[derive(Debug, Clone)] pub struct ConstraintReallocGroup { pub payer: Expr, diff --git a/lang/syn/src/parser/accounts/constraints.rs b/lang/syn/src/parser/accounts/constraints.rs index 02b5c1f31b..f804007182 100644 --- a/lang/syn/src/parser/accounts/constraints.rs +++ b/lang/syn/src/parser/accounts/constraints.rs @@ -50,6 +50,7 @@ pub fn parse_token(stream: ParseStream) -> ParseResult { "executable" => { ConstraintToken::Executable(Context::new(ident.span(), ConstraintExecutable {})) } + "dup" => ConstraintToken::Dup(Context::new(ident.span(), ConstraintDup {})), "mint" => { stream.parse::()?; stream.parse::()?; @@ -538,6 +539,7 @@ pub struct ConstraintGroupBuilder<'ty> { pub realloc: Option>, pub realloc_payer: Option>, pub realloc_zero: Option>, + pub dup: Option>, } impl<'ty> ConstraintGroupBuilder<'ty> { @@ -583,6 +585,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> { realloc: None, realloc_payer: None, realloc_zero: None, + dup: None, } } @@ -795,6 +798,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> { realloc, realloc_payer, realloc_zero, + dup, } = self; // Converts Option> -> Option. @@ -1026,6 +1030,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> { seeds, token_account: if !is_init {token_account} else {None}, mint: if !is_init {mint} else {None}, + dup: into_inner!(dup), }) } @@ -1088,6 +1093,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> { ConstraintToken::ExtensionPermanentDelegate(c) => { self.add_extension_permanent_delegate(c) } + ConstraintToken::Dup(c) => self.add_dup(c), } } @@ -1670,4 +1676,12 @@ impl<'ty> ConstraintGroupBuilder<'ty> { self.extension_permanent_delegate.replace(c); Ok(()) } + + fn add_dup(&mut self, c: Context) -> ParseResult<()> { + if self.dup.is_some() { + return Err(ParseError::new(c.span(), "dup already provided")); + } + self.dup.replace(c); + Ok(()) + } } diff --git a/tests/bench/bench.json b/tests/bench/bench.json index 6f30f0136b..6b4fb34bf1 100644 --- a/tests/bench/bench.json +++ b/tests/bench/bench.json @@ -1677,96 +1677,96 @@ "solanaVersion": "2.3.0", "result": { "binarySize": { - "bench": 932424 + "bench": 1015624 }, "computeUnits": { - "accountInfo1": 702, - "accountInfo2": 1124, - "accountInfo4": 1921, - "accountInfo8": 3480, + "accountInfo1": 760, + "accountInfo2": 1180, + "accountInfo4": 1952, + "accountInfo8": 3530, "accountEmptyInit1": 4770, - "accountEmpty1": 738, + "accountEmpty1": 773, "accountEmptyInit2": 8487, - "accountEmpty2": 1138, + "accountEmpty2": 1173, "accountEmptyInit4": 15915, - "accountEmpty4": 1923, + "accountEmpty4": 1960, "accountEmptyInit8": 30779, - "accountEmpty8": 3500, + "accountEmpty8": 3539, "accountSizedInit1": 4864, - "accountSized1": 786, + "accountSized1": 820, "accountSizedInit2": 8654, - "accountSized2": 1198, + "accountSized2": 1236, "accountSizedInit4": 16236, - "accountSized4": 2031, + "accountSized4": 2070, "accountSizedInit8": 31363, - "accountSized8": 3694, + "accountSized8": 3731, "accountUnsizedInit1": 4967, - "accountUnsized1": 814, + "accountUnsized1": 854, "accountUnsizedInit2": 8841, - "accountUnsized2": 1240, + "accountUnsized2": 1281, "accountUnsizedInit4": 16559, - "accountUnsized4": 2093, + "accountUnsized4": 2134, "accountUnsizedInit8": 31985, - "accountUnsized8": 3797, + "accountUnsized8": 3837, "boxedAccountEmptyInit1": 4864, - "boxedAccountEmpty1": 831, + "boxedAccountEmpty1": 864, "boxedAccountEmptyInit2": 8604, - "boxedAccountEmpty2": 1253, + "boxedAccountEmpty2": 1285, "boxedAccountEmptyInit4": 16075, - "boxedAccountEmpty4": 2077, + "boxedAccountEmpty4": 2112, "boxedAccountEmptyInit8": 31026, "boxedAccountEmpty8": 3797, "boxedAccountSizedInit1": 4952, - "boxedAccountSized1": 877, + "boxedAccountSized1": 913, "boxedAccountSizedInit2": 8756, - "boxedAccountSized2": 1318, + "boxedAccountSized2": 1351, "boxedAccountSizedInit4": 16355, - "boxedAccountSized4": 2185, + "boxedAccountSized4": 2218, "boxedAccountSizedInit8": 31562, "boxedAccountSized8": 3984, "boxedAccountUnsizedInit1": 5044, - "boxedAccountUnsized1": 907, + "boxedAccountUnsized1": 943, "boxedAccountUnsizedInit2": 8916, - "boxedAccountUnsized2": 1352, + "boxedAccountUnsized2": 1386, "boxedAccountUnsizedInit4": 16651, - "boxedAccountUnsized4": 2234, + "boxedAccountUnsized4": 2270, "boxedAccountUnsizedInit8": 32130, "boxedAccountUnsized8": 4063, - "boxedInterfaceAccountMint1": 1092, - "boxedInterfaceAccountMint2": 1490, - "boxedInterfaceAccountMint4": 2276, + "boxedInterfaceAccountMint1": 1128, + "boxedInterfaceAccountMint2": 1523, + "boxedInterfaceAccountMint4": 2307, "boxedInterfaceAccountMint8": 3907, - "boxedInterfaceAccountToken1": 1219, - "boxedInterfaceAccountToken2": 1732, - "boxedInterfaceAccountToken4": 2748, + "boxedInterfaceAccountToken1": 1255, + "boxedInterfaceAccountToken2": 1765, + "boxedInterfaceAccountToken4": 2779, "boxedInterfaceAccountToken8": 4839, - "interfaceAccountMint1": 1107, - "interfaceAccountMint2": 1494, - "interfaceAccountMint4": 2276, + "interfaceAccountMint1": 1145, + "interfaceAccountMint2": 1533, + "interfaceAccountMint4": 2313, "interfaceAccountMint8": 3835, - "interfaceAccountToken1": 1239, - "interfaceAccountToken2": 1753, - "interfaceAccountToken4": 2783, - "interface1": 883, - "interface2": 1029, - "interface4": 1308, - "interface8": 1887, - "program1": 899, - "program2": 1049, - "program4": 1336, - "program8": 1917, - "signer1": 888, - "signer2": 1244, - "signer4": 1904, - "signer8": 3293, - "systemAccount1": 910, - "systemAccount2": 1269, - "systemAccount4": 1972, - "systemAccount8": 3385, - "uncheckedAccount1": 896, - "uncheckedAccount2": 1233, - "uncheckedAccount4": 1861, - "uncheckedAccount8": 3171 + "interfaceAccountToken1": 1279, + "interfaceAccountToken2": 1792, + "interfaceAccountToken4": 2825, + "interface1": 918, + "interface2": 1064, + "interface4": 1345, + "interface8": 1912, + "program1": 934, + "program2": 1084, + "program4": 1373, + "program8": 1956, + "signer1": 923, + "signer2": 1272, + "signer4": 1957, + "signer8": 3332, + "systemAccount1": 945, + "systemAccount2": 1304, + "systemAccount4": 2009, + "systemAccount8": 3424, + "uncheckedAccount1": 931, + "uncheckedAccount2": 1263, + "uncheckedAccount4": 1911, + "uncheckedAccount8": 3207 }, "stackMemory": { "account_info1": 46, @@ -1859,4 +1859,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/duplicate-mutable-accounts/Anchor.toml b/tests/duplicate-mutable-accounts/Anchor.toml new file mode 100644 index 0000000000..6cf3d81575 --- /dev/null +++ b/tests/duplicate-mutable-accounts/Anchor.toml @@ -0,0 +1,9 @@ +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[programs.localnet] +duplicate_mutable_accounts = "4D6rvpR7TSPwmFottLGa5gpzMcJ76kN8bimQHV9rogjH" + +[scripts] +test = "yarn run ts-mocha -t 1000000 tests/*.ts" diff --git a/tests/duplicate-mutable-accounts/Cargo.toml b/tests/duplicate-mutable-accounts/Cargo.toml new file mode 100644 index 0000000000..36c9df94de --- /dev/null +++ b/tests/duplicate-mutable-accounts/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = [ + "programs/duplicate-mutable-accounts", +] +resolver = "2" + +[profile.release] +overflow-checks = true diff --git a/tests/duplicate-mutable-accounts/package.json b/tests/duplicate-mutable-accounts/package.json new file mode 100644 index 0000000000..0996041cf8 --- /dev/null +++ b/tests/duplicate-mutable-accounts/package.json @@ -0,0 +1,19 @@ +{ + "name": "duplicate-mutable-accounts", + "version": "0.31.1", + "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": ">=17" + }, + "scripts": { + "test": "anchor test" + } +} diff --git a/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/Cargo.toml b/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/Cargo.toml new file mode 100644 index 0000000000..e88eab0ec7 --- /dev/null +++ b/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "duplicate-mutable-accounts" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "duplicate_mutable_accounts" + +[features] +no-entrypoint = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = { path = "../../../../lang" } \ No newline at end of file diff --git a/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/Xargo.toml b/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/Xargo.toml new file mode 100644 index 0000000000..1744f098ae --- /dev/null +++ b/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/src/lib.rs b/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/src/lib.rs new file mode 100644 index 0000000000..eaa1725221 --- /dev/null +++ b/tests/duplicate-mutable-accounts/programs/duplicate-mutable-accounts/src/lib.rs @@ -0,0 +1,140 @@ +use anchor_lang::prelude::*; + +// Intentionally different program id than the one defined in Anchor.toml. +declare_id!("4D6rvpR7TSPwmFottLGa5gpzMcJ76kN8bimQHV9rogjH"); + +#[program] +pub mod duplicate_mutable_accounts { + use super::*; + + pub fn initialize(ctx: Context, initial: u64) -> Result<()> { + ctx.accounts.data_account.count = initial; + Ok(()) + } + + // This one should FAIL if the same mutable account is passed twice + // (Anchor disallows duplicate mutable accounts here). + pub fn fails_duplicate_mutable(ctx: Context) -> Result<()> { + ctx.accounts.account1.count += 1; + ctx.accounts.account2.count += 1; + Ok(()) + } + + // This one should SUCCEED even if the same account is passed twice, + // thanks to the `dup` constraint. + pub fn allows_duplicate_mutable(ctx: Context) -> Result<()> { + ctx.accounts.account1.count += 1; + ctx.accounts.account2.count += 1; + Ok(()) + } + + // Readonly duplicates should always be fine: we just read (no mutation). + pub fn allows_duplicate_readonly(_ctx: Context) -> Result<()> { + Ok(()) + } + + // Test nested account structures + pub fn nested_duplicate(ctx: Context) -> Result<()> { + // Both wrappers contain mutable counters + ctx.accounts.wrapper1.counter.count += 1; + ctx.accounts.wrapper2.counter.count += 1; + Ok(()) + } + + // Test that remaining_accounts are accessible and can be used + pub fn use_remaining_accounts(ctx: Context) -> Result<()> { + ctx.accounts.account1.count += 1; + + msg!( + "Processing {} remaining accounts", + ctx.remaining_accounts.len() + ); + for account_info in ctx.remaining_accounts.iter() { + if account_info.is_writable { + msg!("Remaining account {} is writable", account_info.key); + } + } + Ok(()) + } + + // Test initializing multiple accounts with the same payer + pub fn init_multiple_with_same_payer( + ctx: Context, + initial1: u64, + initial2: u64, + ) -> Result<()> { + ctx.accounts.data_account1.count = initial1; + ctx.accounts.data_account2.count = initial2; + Ok(()) + } +} + +#[account] +pub struct Counter { + pub count: u64, +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(init, payer = user, space = 8 + 8)] + pub data_account: Account<'info, Counter>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct FailsDuplicateMutable<'info> { + #[account(mut)] + pub account1: Account<'info, Counter>, + #[account(mut)] + pub account2: Account<'info, Counter>, +} + +// Allow the same mutable account to be supplied twice via the `dup` constraint. +#[derive(Accounts)] +pub struct AllowsDuplicateMutable<'info> { + #[account(mut)] + pub account1: Account<'info, Counter>, + #[account(mut, dup)] + pub account2: Account<'info, Counter>, +} + +// Readonly accounts (no `mut`), duplicates allowed by nature. +#[derive(Accounts)] +pub struct AllowsDuplicateReadonly<'info> { + pub account1: Account<'info, Counter>, + pub account2: Account<'info, Counter>, +} + +// Nested account structures +#[derive(Accounts)] +pub struct CounterWrapper<'info> { + #[account(mut)] + pub counter: Account<'info, Counter>, +} + +#[derive(Accounts)] +pub struct NestedDuplicate<'info> { + pub wrapper1: CounterWrapper<'info>, + pub wrapper2: CounterWrapper<'info>, +} + +// Test using remaining_accounts +#[derive(Accounts)] +pub struct UseRemainingAccounts<'info> { + #[account(mut)] + pub account1: Account<'info, Counter>, +} + +// Test initializing multiple accounts with the same payer +#[derive(Accounts)] +pub struct InitMultipleWithSamePayer<'info> { + #[account(init, payer = user, space = 8 + 8)] + pub data_account1: Account<'info, Counter>, + #[account(init, payer = user, space = 8 + 8)] + pub data_account2: Account<'info, Counter>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} diff --git a/tests/duplicate-mutable-accounts/tests/duplicate-mutable-accounts.ts b/tests/duplicate-mutable-accounts/tests/duplicate-mutable-accounts.ts new file mode 100644 index 0000000000..4fdc5f5702 --- /dev/null +++ b/tests/duplicate-mutable-accounts/tests/duplicate-mutable-accounts.ts @@ -0,0 +1,234 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program, AnchorError } from "@coral-xyz/anchor"; +import { DuplicateMutableAccounts } from "../target/types/duplicate_mutable_accounts"; +import { assert } from "chai"; + +describe("duplicate-mutable-accounts", () => { + anchor.setProvider(anchor.AnchorProvider.env()); + const provider = anchor.getProvider() as anchor.AnchorProvider; + const program = anchor.workspace + .DuplicateMutableAccounts as Program; + + // Payer used by #[account(init, payer = user, ...)] + const user_wallet = anchor.web3.Keypair.generate(); + + // Two regular system accounts to hold Counter state (must sign on init) + const dataAccount1 = anchor.web3.Keypair.generate(); + const dataAccount2 = anchor.web3.Keypair.generate(); + + it("Initialize accounts", async () => { + // 1) Fund user_wallet so it can pay rent + const airdropSig = await provider.connection.requestAirdrop( + user_wallet.publicKey, + 2 * anchor.web3.LAMPORTS_PER_SOL + ); + await provider.connection.confirmTransaction(airdropSig); + + // 2) Create & init dataAccount1 (must sign with dataAccount1) + await program.methods + .initialize(new anchor.BN(100)) + .accounts({ + dataAccount: dataAccount1.publicKey, + user: user_wallet.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([user_wallet, dataAccount1]) // <- include the new account keypair + .rpc(); + + // 3) Create & init dataAccount2 + await program.methods + .initialize(new anchor.BN(300)) + .accounts({ + dataAccount: dataAccount2.publicKey, + user: user_wallet.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([user_wallet, dataAccount2]) // <- include the new account keypair + .rpc(); + }); + + it("Should fail with duplicate mutable accounts", async () => { + // Ensure the accounts are initialized + const account1 = await program.account.counter.fetch( + dataAccount1.publicKey + ); + const account2 = await program.account.counter.fetch( + dataAccount2.publicKey + ); + assert.strictEqual(account1.count.toNumber(), 100); + assert.strictEqual(account2.count.toNumber(), 300); + + try { + await program.methods + .failsDuplicateMutable() + .accounts({ + account1: dataAccount1.publicKey, + account2: dataAccount1.publicKey, // <- SAME account to trigger the check + }) + .rpc(); + assert.fail("Expected duplicate mutable violation"); + } catch (e) { + assert.instanceOf(e, AnchorError); + const err = e as AnchorError; + assert.strictEqual( + err.error.errorCode.code, + "ConstraintDuplicateMutableAccount" + ); + assert.strictEqual(err.error.errorCode.number, 2040); + } + }); + + it("Should succeed with duplicate mutable accounts when using dup constraint", async () => { + // This instruction MUST have `#[account(mut, dup)]` on at least one account + await program.methods + .allowsDuplicateMutable() + .accounts({ + account1: dataAccount1.publicKey, + account2: dataAccount1.publicKey, // same account allowed via `dup` + }) + .rpc(); + assert.ok(true); + }); + + it("Should allow duplicate readonly accounts", async () => { + // Readonly accounts can be duplicated without any constraint + await program.methods + .allowsDuplicateReadonly() + .accounts({ + account1: dataAccount1.publicKey, + account2: dataAccount1.publicKey, // same account, both readonly + }) + .rpc(); + assert.ok(true, "Readonly duplicates are allowed"); + }); + + it("Should block nested duplicate accounts", async () => { + try { + await program.methods + .nestedDuplicate() + .accounts({ + wrapper1: { + counter: dataAccount1.publicKey, + }, + wrapper2: { + counter: dataAccount1.publicKey, // Same counter in both wrappers! + }, + }) + .rpc(); + + assert.fail( + "Nested structures with duplicate accounts should be blocked" + ); + } catch (e) { + // Should be blocked with the fix + assert.ok( + e.message.includes("ConstraintDuplicateMutableAccount") || + e.message.includes("duplicate") || + e.message.includes("2040"), + "Nested duplicate correctly blocked" + ); + } + }); + + it("Should block duplicate in remainingAccounts", async () => { + try { + await program.methods + .failsDuplicateMutable() + .accounts({ + account1: dataAccount1.publicKey, + account2: dataAccount2.publicKey, // Different account + }) + .remainingAccounts([ + { + pubkey: dataAccount1.publicKey, // duplicate via remainingAccounts + isWritable: true, + isSigner: false, + }, + ]) + .rpc(); + + assert.fail("Should have been blocked - remainingAccounts bypass failed"); + } catch (e) { + // Should be blocked with framework-level security fix + assert.ok( + e.message.includes("ConstraintDuplicateMutableAccount") || + e.message.includes("duplicate") || + e.message.includes("2040"), + "Successfully blocked with framework-level validation" + ); + } + }); + + it("Should allow using remaining_accounts without duplicates", async () => { + // Get initial counts + const beforeAccount1 = await program.account.counter.fetch( + dataAccount1.publicKey + ); + + // Call with valid remaining accounts (no duplicates) + await program.methods + .useRemainingAccounts() + .accounts({ + account1: dataAccount1.publicKey, + }) + .remainingAccounts([ + { + pubkey: dataAccount2.publicKey, + isWritable: true, + isSigner: false, + }, + ]) + .rpc(); + + // Verify account was incremented + const afterAccount1 = await program.account.counter.fetch( + dataAccount1.publicKey + ); + + assert.equal( + afterAccount1.count.toNumber(), + beforeAccount1.count.toNumber() + 1, + "Account1 should be incremented" + ); + }); + + it("Should allow initializing multiple accounts with the same payer", async () => { + // Create two new keypairs for the accounts to be initialized + const newAccount1 = anchor.web3.Keypair.generate(); + const newAccount2 = anchor.web3.Keypair.generate(); + + // Initialize both accounts using the same payer + // This should succeed because: + // 1. The payer is a Signer, which is excluded from duplicate checks + // 2. The accounts being initialized are different (newAccount1 vs newAccount2) + await program.methods + .initMultipleWithSamePayer(new anchor.BN(500), new anchor.BN(600)) + .accounts({ + dataAccount1: newAccount1.publicKey, + dataAccount2: newAccount2.publicKey, + user: user_wallet.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .signers([user_wallet, newAccount1, newAccount2]) + .rpc(); + + // Verify both accounts were created with correct values + const account1Data = await program.account.counter.fetch( + newAccount1.publicKey + ); + const account2Data = await program.account.counter.fetch( + newAccount2.publicKey + ); + + assert.equal( + account1Data.count.toNumber(), + 500, + "First account should have count 500" + ); + assert.equal( + account2Data.count.toNumber(), + 600, + "Second account should have count 600" + ); + }); +}); diff --git a/tests/duplicate-mutable-accounts/tsconfig.json b/tests/duplicate-mutable-accounts/tsconfig.json new file mode 100644 index 0000000000..c7f23d9eaf --- /dev/null +++ b/tests/duplicate-mutable-accounts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai", "node"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/tests/events/tests/events.ts b/tests/events/tests/events.ts index 33813bcba1..141d5a3dcd 100644 --- a/tests/events/tests/events.ts +++ b/tests/events/tests/events.ts @@ -69,7 +69,7 @@ describe("Events", () => { const txResult = await program.provider.connection.getTransaction( txHash, { - commitment: "confirmed", + ...confirmOptions, maxSupportedTransactionVersion: 0, } ); diff --git a/tests/misc/programs/misc-optional/src/context.rs b/tests/misc/programs/misc-optional/src/context.rs index 1a06393a95..fcbd761b58 100644 --- a/tests/misc/programs/misc-optional/src/context.rs +++ b/tests/misc/programs/misc-optional/src/context.rs @@ -749,7 +749,7 @@ pub struct InitManyAssociatedTokenAccounts<'info> { pub struct TestMultipleZeroConstraint<'info> { #[account(zero)] pub one: Option>, - #[account(zero)] + #[account(zero, dup)] // Allow duplicate accounts to test the error pub two: Option>, } @@ -757,7 +757,7 @@ pub struct TestMultipleZeroConstraint<'info> { pub struct TestInitAndZero<'info> { #[account(init, payer = payer, space = Data::DISCRIMINATOR.len() + Data::LEN)] pub init: Option>, - #[account(zero)] + #[account(zero, dup)] // Allow duplicate accounts to test the error pub zero: Option>, #[account(mut)] pub payer: Option>, diff --git a/tests/misc/programs/misc/src/context.rs b/tests/misc/programs/misc/src/context.rs index d19c84c562..7fb3571409 100644 --- a/tests/misc/programs/misc/src/context.rs +++ b/tests/misc/programs/misc/src/context.rs @@ -821,7 +821,7 @@ pub struct Empty {} pub struct TestMultipleZeroConstraint<'info> { #[account(zero)] pub one: Account<'info, Data>, - #[account(zero)] + #[account(zero, dup)] // Allow duplicate accounts to test the error pub two: Account<'info, Data>, } @@ -829,7 +829,7 @@ pub struct TestMultipleZeroConstraint<'info> { pub struct TestInitAndZero<'info> { #[account(init, payer = payer, space = Data::DISCRIMINATOR.len() + Data::LEN)] pub init: Account<'info, Data>, - #[account(zero)] + #[account(zero, dup)] // Allow duplicate accounts to test the error pub zero: Account<'info, Data>, #[account(mut)] pub payer: Signer<'info>, diff --git a/tests/package.json b/tests/package.json index bfd58dabd7..7571a236b2 100644 --- a/tests/package.json +++ b/tests/package.json @@ -52,6 +52,7 @@ "multiple-suites", "multiple-suites-run-single", "bpf-upgradeable-state", + "duplicate-mutable-accounts", "signature-verification" ], "dependencies": { diff --git a/tests/realloc/programs/realloc/src/lib.rs b/tests/realloc/programs/realloc/src/lib.rs index 3ac5eb5dc2..500d2c2380 100644 --- a/tests/realloc/programs/realloc/src/lib.rs +++ b/tests/realloc/programs/realloc/src/lib.rs @@ -93,6 +93,7 @@ pub struct Realloc2<'info> { realloc = Sample::space((len + 10) as usize), realloc::payer = authority, realloc::zero = false, + dup, // Allow duplicate accounts )] pub sample2: Account<'info, Sample>,