Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b425392
feat: Add duplicate mutable account constraint
swaroop-osec Aug 28, 2025
505727e
feat: tests for duplicate mutable accounts
swaroop-osec Aug 28, 2025
e42b1bc
feat: add test for duplicate mutable accounts in workflow
swaroop-osec Aug 28, 2025
da757c8
style(tests): prettier
swaroop-osec Aug 28, 2025
b077343
chore: update benchmarks
swaroop-osec Aug 28, 2025
62b76d0
feat: exclude UncheckedAccounts from duplicate mutable account checks
swaroop-osec Aug 28, 2025
4b3b85c
feat(tests): add duplicate-mutable-accounts to test scripts
swaroop-osec Aug 28, 2025
da22fa8
feat: enhance duplicate mutable account checks for optional fields
swaroop-osec Aug 28, 2025
0ee61f8
chore(bench): update
swaroop-osec Aug 28, 2025
a6f6c57
fix: update program IDs
swaroop-osec Aug 28, 2025
4035b0e
feat: allow duplicate accounts in realloc2 ix
swaroop-osec Aug 28, 2025
c65be84
chore(bench): update
swaroop-osec Aug 28, 2025
d55c2ef
chore(bench): update
swaroop-osec Aug 28, 2025
5c228f5
feat(tests): allow duplicate accounts in misc tests
swaroop-osec Aug 29, 2025
8dd5c8d
fix(bench):update
swaroop-osec Aug 29, 2025
a42335a
fix: update program ID for duplicate mutable accounts
swaroop-osec Aug 29, 2025
5237b78
fix: update program ID
swaroop-osec Aug 29, 2025
4a1503e
fix(bench): update
swaroop-osec Aug 29, 2025
6389a61
chore(docs): Updated docs and CHANGELOG.md
swaroop-osec Aug 29, 2025
bd20367
refactor: ignore init accounts
swaroop-osec Sep 16, 2025
e5fd855
fix(bench): update
swaroop-osec Sep 16, 2025
3d5406a
Merge branch 'master' into feat/issue-3825
swaroop-osec Sep 16, 2025
bb5a597
chore: formating
swaroop-osec Sep 16, 2025
6c3535b
refactor: optimize duplicate mutable checks generation
swaroop-osec Sep 16, 2025
3110653
refactor: replace BTreeSet with HashSet
swaroop-osec Sep 16, 2025
c3e4f45
Merge branch 'master' into feat/issue-3825
swaroop-osec Sep 19, 2025
a289a6d
Merge branch 'master' into feat/issue-3825
swaroop-osec Oct 23, 2025
8e61e4a
(chore): Update benchmarks
swaroop-osec Oct 23, 2025
9bebf80
test(events): use confirmOptions for transaction handling
swaroop-osec Oct 23, 2025
9516a8f
Merge branch 'master' into feat/issue-3825
swaroop-osec Oct 24, 2025
5f52676
chore: Update CHANGELOG.md
swaroop-osec Oct 25, 2025
8fa5a37
feat(lang): Added checks for duplicate mutable accounts in `remaining…
swaroop-osec Oct 30, 2025
7797270
feat(tests): Add nested duplicate account test to prevent mutable acc…
swaroop-osec Oct 30, 2025
bbff6e9
chore(bench): Update benchmarks
swaroop-osec Oct 30, 2025
74f1898
fix(lang): Exclude Signer accounts from duplicate mutable checks in a…
swaroop-osec Oct 31, 2025
2ab93ce
feat(tests): Add test to initialize multiple accounts with the same p…
swaroop-osec Oct 31, 2025
10da01a
chore(bench): Update
swaroop-osec Oct 31, 2025
116bf4c
Merge branch 'master' into feat/issue-3825
swaroop-osec Nov 21, 2025
21b3461
fix: package.json
swaroop-osec Nov 21, 2025
a196525
refactor: remove unused confirmOptions from event tests
swaroop-osec Nov 21, 2025
11c7e47
chore: update benchmarks
swaroop-osec Nov 21, 2025
0557e75
chore: update benchmarks
swaroop-osec Nov 21, 2025
ad3f631
Merge branch 'master' into feat/issue-3825
swaroop-osec Nov 25, 2025
aea833c
Update benchmarks
swaroop-osec Nov 25, 2025
b1a06c4
Merge branch 'master' into feat/issue-3825
swaroop-osec Nov 29, 2025
6f0944f
chore: Update benchmarks
swaroop-osec Nov 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/reusable-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ jobs:
path: tests/declare-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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The minor version will be incremented upon a breaking change and the patch versi
- lang: Replace `solana-program` crate with smaller crates ([#3819](https://github.com/solana-foundation/anchor/pull/3819)).
- cli: Replace `anchor verify` to use `solana-verify` under the hood, adding automatic installation via AVM, local path support, and future-proof argument passing ([#3768](https://github.com/solana-foundation/anchor/pull/3768)).
- cli: Make `anchor deploy` to upload the IDL to the cluster by default unless `--no-idl` is passed ([#3863](https://github.com/solana-foundation/anchor/pull/3863)).
- lang: Disallow duplicate mutable accounts by default. But allows duplicate mutable accounts in instruction contexts using `dup` constraint ([#3899](https://github.com/solana-foundation/anchor/pull/3899)).

### Fixes

Expand Down
6 changes: 3 additions & 3 deletions bench/BINARY_SIZE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ The programs and their tests are located in [/tests/bench](https://github.com/co

Solana version: 2.1.0

| Program | Binary Size | - |
| ------- | ----------- | --- |
| bench | 1,041,928 | - |
| Program | Binary Size | - |
| ------- | ----------- | ---------------------- |
| bench | 1,058,168 | 🔴 **+16,240 (1.56%)** |

### Notable changes

Expand Down
27 changes: 27 additions & 0 deletions docs/content/docs/references/account-constraints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ Examples: [Github](https://github.com/solana-developers/anchor-examples/tree/mai
#[account(mut @ <custom_error>)]
```

### `#[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 @ <custom_error>)]
```

```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<AllowsDuplicateMutable>) -> Result<()> {
Ok(())
}
```

### `#[account(init)]`

Description: Creates the account via a CPI to the system program and initializes
Expand Down
16 changes: 16 additions & 0 deletions lang/derive/accounts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ use syn::parse_macro_input;
/// </tr>
/// <tr>
/// <td>
/// <code>#[account(dup)]</code> <br><br>
/// </td>
/// <td>
/// Allows the same mutable account to be passed multiple times within the same instruction context.<br>
/// By default, Anchor will prevents duplicate mutable accounts to avoid potential security issues and unintended behavior.<br>
/// The <code>dup</code> constraint explicitly allows this for cases where it's intentional and safe.<br>
/// This constraint only applies to mutable accounts (<code>mut</code>). Readonly accounts naturally allow duplicates without requiring the <code>dup</code> constraint.<br>
/// Example:
/// <pre><code>
/// #[account(mut)]
/// pub account1: Account<'info, Counter>,
/// </code></pre>
/// </td>
/// </tr>
/// <tr>
/// <td>
/// <code>#[account(init, payer = &lt;target_account&gt;, space = &lt;num_bytes&gt;)]</code>
/// </td>
/// <td>
Expand Down
3 changes: 3 additions & 0 deletions lang/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,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,

// Require
/// 2500 - A require expression was violated
Expand Down
5 changes: 5 additions & 0 deletions lang/syn/src/codegen/accounts/constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub fn linearize(c_group: &ConstraintGroup) -> Vec<Constraint> {
init,
zeroed,
mutable,
dup,
signer,
has_one,
raw,
Expand Down Expand Up @@ -111,6 +112,9 @@ pub fn linearize(c_group: &ConstraintGroup) -> Vec<Constraint> {
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));
}
Expand Down Expand Up @@ -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),
Expand Down
65 changes: 65 additions & 0 deletions lang/syn/src/codegen/accounts/try_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,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<proc_macro2::TokenStream> = non_init_fields
.iter()
Expand All @@ -168,6 +171,7 @@ pub fn generate_constraints(accs: &AccountsStruct) -> proc_macro2::TokenStream {

quote! {
#(#init_fields)*
#duplicate_checks
#(#access_checks)*
}
}
Expand Down Expand Up @@ -202,3 +206,64 @@ 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 & 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
_ => Some(f),
}
}
_ => None,
})
.collect();

if candidates.len() <= 1 {
// 0 or 1 -> no duplicates possible
return quote! {};
}

// Generate validation code using BTreeSet
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 BTreeSet for efficiency
{
let mut __mutable_accounts = std::collections::BTreeSet::new();
#(
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));
}
}
)*
}
}
}
10 changes: 10 additions & 0 deletions lang/syn/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ pub struct ConstraintGroup {
pub init: Option<ConstraintInitGroup>,
pub zeroed: Option<ConstraintZeroed>,
pub mutable: Option<ConstraintMut>,
pub dup: Option<ConstraintDup>,
pub signer: Option<ConstraintSigner>,
pub owner: Option<ConstraintOwner>,
pub rent_exempt: Option<ConstraintRentExempt>,
Expand All @@ -702,6 +703,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()
}
Expand All @@ -720,6 +725,7 @@ pub enum Constraint {
Init(ConstraintInitGroup),
Zeroed(ConstraintZeroed),
Mut(ConstraintMut),
Dup(ConstraintDup),
Signer(ConstraintSigner),
HasOne(ConstraintHasOne),
Raw(ConstraintRaw),
Expand All @@ -742,6 +748,7 @@ pub enum ConstraintToken {
Init(Context<ConstraintInit>),
Zeroed(Context<ConstraintZeroed>),
Mut(Context<ConstraintMut>),
Dup(Context<ConstraintDup>),
Signer(Context<ConstraintSigner>),
HasOne(Context<ConstraintHasOne>),
Raw(Context<ConstraintRaw>),
Expand Down Expand Up @@ -807,6 +814,9 @@ pub struct ConstraintMut {
pub error: Option<Expr>,
}

#[derive(Debug, Clone)]
pub struct ConstraintDup {}

#[derive(Debug, Clone)]
pub struct ConstraintReallocGroup {
pub payer: Expr,
Expand Down
14 changes: 14 additions & 0 deletions lang/syn/src/parser/accounts/constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub fn parse_token(stream: ParseStream) -> ParseResult<ConstraintToken> {
"executable" => {
ConstraintToken::Executable(Context::new(ident.span(), ConstraintExecutable {}))
}
"dup" => ConstraintToken::Dup(Context::new(ident.span(), ConstraintDup {})),
"mint" => {
stream.parse::<Token![:]>()?;
stream.parse::<Token![:]>()?;
Expand Down Expand Up @@ -543,6 +544,7 @@ pub struct ConstraintGroupBuilder<'ty> {
pub realloc: Option<Context<ConstraintRealloc>>,
pub realloc_payer: Option<Context<ConstraintReallocPayer>>,
pub realloc_zero: Option<Context<ConstraintReallocZero>>,
pub dup: Option<Context<ConstraintDup>>,
}

impl<'ty> ConstraintGroupBuilder<'ty> {
Expand Down Expand Up @@ -588,6 +590,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> {
realloc: None,
realloc_payer: None,
realloc_zero: None,
dup: None,
}
}

Expand Down Expand Up @@ -800,6 +803,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> {
realloc,
realloc_payer,
realloc_zero,
dup,
} = self;

// Converts Option<Context<T>> -> Option<T>.
Expand Down Expand Up @@ -1031,6 +1035,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),
})
}

Expand Down Expand Up @@ -1093,6 +1098,7 @@ impl<'ty> ConstraintGroupBuilder<'ty> {
ConstraintToken::ExtensionPermanentDelegate(c) => {
self.add_extension_permanent_delegate(c)
}
ConstraintToken::Dup(c) => self.add_dup(c),
}
}

Expand Down Expand Up @@ -1675,4 +1681,12 @@ impl<'ty> ConstraintGroupBuilder<'ty> {
self.extension_permanent_delegate.replace(c);
Ok(())
}

fn add_dup(&mut self, c: Context<ConstraintDup>) -> ParseResult<()> {
if self.dup.is_some() {
return Err(ParseError::new(c.span(), "dup already provided"));
}
self.dup.replace(c);
Ok(())
}
}
2 changes: 1 addition & 1 deletion tests/bench/bench.json
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,7 @@
"solanaVersion": "2.1.0",
"result": {
"binarySize": {
"bench": 1041928
"bench": 1058168
},
"computeUnits": {
"accountInfo1": 571,
Expand Down
9 changes: 9 additions & 0 deletions tests/duplicate-mutable-accounts/Anchor.toml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions tests/duplicate-mutable-accounts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[workspace]
members = [
"programs/duplicate-mutable-accounts",
]
resolver = "2"

[profile.release]
overflow-checks = true
19 changes: 19 additions & 0 deletions tests/duplicate-mutable-accounts/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []
Loading