DEV Community

Cover image for Arc 12 Catch-up: Advanced Testing and Security
Matthew Revell for 100 Days of Solana

Posted on

Arc 12 Catch-up: Advanced Testing and Security

Security bugs on Solana do not always come from obscure runtime behaviour or complicated cryptography.

Often, a program trusted the wrong account, accepted the wrong authority, or assumed that two pieces of state belonged together without proving it.

Arc 12 was about finding those assumptions before an attacker does.

Across Days 78–84, we audited our own code, tightened account validation, wrote transactions designed to break the program, used property-based testing and fuzzing to search for edge cases, and reproduced a real class of account-substitution exploit.

The rule running through the whole arc was simple:

Every account and input is untrusted until the program proves otherwise.

We began by auditing our own code

The first challenge took us back to an Anchor program we had already written.

Until now, we had mainly tested whether its instructions worked when the client supplied the right accounts. This time, we looked at each instruction from the other direction.

What prevents the client from supplying the wrong ones?

For every account in every #[derive(Accounts)] struct, we asked two questions:

  • Does this account have the correct owner?
  • If it authorises an action, has it signed the transaction?

Those checks sit underneath much of Solana program security.

An account can contain data that looks exactly as expected and still be unsafe to trust. It might be the right type of account in the wrong context, another user’s vault, or a token account tied to a different mint.

It might also be a different account containing data designed to look legitimate.

Anchor can enforce many of the checks we need.

Signer<'info> requires an account to have signed.

Account<'info, T> checks that the expected program owns the account and that its data matches the expected Anchor type.

PDA constraints verify that an account was derived from the required seeds.

Relationship constraints check that accounts belong together.

The danger appears when we step outside those protections without adding the missing validation ourselves.

There are legitimate reasons to use UncheckedAccount. It does not mean the account is automatically unsafe.

It means Anchor is not checking it for us.

Anchor even requires a /// CHECK: comment explaining why leaving the account unchecked is safe. The responsibility has visibly moved into our code.

By the end of the audit, we had a method we could apply to any instruction:

  1. List every account.
  2. Write down what the program assumes about it.
  3. Find the type, constraint or code that enforces that assumption.
  4. Flag anything the program simply takes on trust.

Security bugs often appear where the developer knows something should be true but the program never checks it.

Anchor constraints made those assumptions enforceable

The next challenge gave us an intentionally insecure vault withdrawal and asked us to fix it.

The secure version needed to establish that:

  • The authority had signed.
  • The vault belonged to our program.
  • The vault was the expected PDA.
  • The supplied authority matched the authority stored in the vault.
  • Any account being changed was writable.

We could perform some of those checks inside the handler. Anchor gives us a better place for most of them: the accounts struct.

The hardened instruction used:

  • Signer<'info> to require a signature.
  • Account<'info, Vault> to check ownership and account type.
  • seeds and bump to verify the vault PDA.
  • has_one = authority to connect the stored authority to the live signer.
  • mut to require writable access where state would change.

Anchor evaluates those constraints before the handler runs.

If the supplied accounts do not satisfy them, the instruction logic never starts.

The Web2 comparison is validation at the edge of a service. Authentication, schema validation and authorisation happen before untrusted input reaches the code that changes important state.

Anchor’s account constraints play a similar role.

They also make the security model easier to review. Someone reading the accounts struct can see which signer is required, how the PDA is derived and which accounts must be related.

Comments and client-side checks cannot provide the same protection.

A client can be replaced.

A comment can become outdated.

A runtime constraint applies to every transaction.

Then we tried to break the program

Most tests begin with the expected path.

A user deposits funds.

The balance increases.

The correct authority withdraws.

The balance decreases.

Those tests prove that the program works when everyone follows the rules. They say much less about what happens when someone deliberately breaks them.

Day 80 changed the job of the test suite.

Using LiteSVM, we wrote transactions that behaved like attacks.

We tried to withdraw with the wrong authority, substitute another account for the expected PDA, and withdraw more than the vault contained.

Each test challenged a specific security claim:

  • Only the recorded authority can withdraw.
  • Only the canonical vault PDA is accepted.
  • An excessive withdrawal cannot underflow the balance.

It was not enough to assert that the transaction failed.

A transaction can fail for irrelevant reasons. The setup might be wrong. An account might be missing. The instruction might never reach the validation we wanted to test.

So we checked the error too.

A wrong authority should fail with the authority error.

A substituted PDA should fail its seed constraint.

An excessive withdrawal should return the intended balance or arithmetic error.

That distinction matters.

“The attack did not work” is useful.

“The intended defence stopped the attack” is much better evidence.

This matters most for instructions that move assets, change authorities, close accounts, perform arithmetic with user-supplied values, use PDA signing or make Cross-Program Invocations.

For each one, the test suite should ask uncomfortable questions.

What happens if the wrong person signs?

What happens if nobody signs?

What happens if the account is valid but belongs to someone else?

What happens if two individually valid accounts have no valid relationship?

What happens at zero or at the maximum value?

What happens when instructions run in an order we did not expect?

The accounts struct gives us the outline of the security model.

The adversarial tests try to violate it.

Property tests searched beyond the attacks we imagined

Adversarial tests are still limited by our own ideas.

We test the wrong signer because we already know signer checks matter.

We test an excessive withdrawal because we know underflow is possible.

We test the wrong PDA because we understand account derivation.

Less obvious failures often appear in awkward values or unexpected combinations of otherwise valid actions.

Day 81 introduced property-based testing and fuzzing to search a much larger space.

A normal test begins with a specific example:

Deposit 10 into a balance of 20 and expect 30.

A property test begins with a rule:

A successful deposit must never reduce the balance.

If the deposit would overflow, the operation must return an error rather than wrap around.

Using proptest, we expressed that rule in pure Rust and let the framework generate many combinations of values.

That included values near zero, values close to the u64 limit and combinations we would be unlikely to choose by hand.

When a property failed, the tool reduced the input to a smaller counterexample.

A huge random value might reveal the bug, but a minimal failing case usually makes it easier to understand.

The focus moves away from listing examples and towards stating what must always remain true.

For a vault, useful properties might include:

  • A deposit never reduces the balance.
  • A withdrawal never increases it.
  • A failed instruction leaves state unchanged.
  • A user cannot withdraw more than the vault contains.
  • Only the recorded authority can withdraw.
  • No valid sequence of operations creates or loses funds unexpectedly.

These are invariants: rules that should survive every valid state transition.

That makes them especially useful when the bug appears only after a sequence of instructions rather than in one isolated call.

Trident extended the same idea into the Anchor program itself.

Instead of testing only pure Rust arithmetic, it generated instructions and values, exercised the program, and checked the properties we supplied.

That allowed it to explore unusual sequences of deposits and withdrawals, generated account combinations, failed transactions and boundary values.

The developer still decides which rules matter.

The fuzzer searches for a sequence that breaks them.

When it finds one, the counterexample should become a named regression test.

That gives us a useful workflow:

  1. State an invariant.
  2. Let the fuzzer search for a counterexample.
  3. Understand the failure.
  4. Fix the program.
  5. Add the failing case to the permanent test suite.

Fuzzing finds the case.

The regression test makes sure we do not forget it.

We reproduced an account-substitution exploit

Day 82 turned the arc’s central warning into a working exploit.

We built a deliberately insecure program that read configuration data from an unchecked account.

The account looked valid.

Its data had the expected shape, and the values were where the program expected them to be.

But it was not the account the program should have trusted.

Because the instruction checked the data without properly establishing the account’s identity and ownership, it accepted a forged configuration and allowed an unauthorised result.

This reproduced the broad class of mistake seen in incidents such as Cashio and Wormhole: trusting a supplied account without proving that it was the account the program expected.

The details differed.

Cashio involved a broken chain of trust around supplied accounts.

Wormhole accepted a substituted account in place of the real Instructions sysvar, allowing the attacker to bypass signature verification.

The shared lesson is that convincing data is not enough.

The program must establish which account supplied it.

In our exercise, the fix was small.

We replaced the unchecked configuration account with Account<'info, Config>.

Anchor then checked that the expected program owned the account and that its data matched the expected Anchor type before the handler read it.

A different account could still contain similar data.

It could no longer pass validation.

This is why ownership and identity are part of the meaning of account data on Solana.

A configuration account owned by our program is program state.

Similar data in an account controlled elsewhere is untrusted input.

Without the right checks, vulnerable code may not see the difference.

The strongest fixes were often structural

A recurring theme throughout the arc was that security fixes do not always require more handler code.

Often, the cleaner answer is to choose account types and constraints that reject the bad request before the handler runs.

Use a checked account type instead of manually reading an unchecked account.

Declare PDA seeds rather than comparing addresses later.

Use has_one to enforce an account relationship.

Use checked arithmetic rather than assuming values will remain within the expected range.

There will always be business rules that constraints cannot express, but structural protections have an important advantage: they apply regardless of which client sends the transaction.

A well-behaved frontend does not make a program secure.

Someone can always construct the transaction directly.

The program has to defend itself.

We turned the arc into a security checklist

By Day 83, we had audited an existing program, hardened its constraints, written malicious transactions, generated edge cases and reproduced an account-substitution exploit.

The next challenge was to turn that work into something we could use again.

We published a practical Solana security checklist on DEV.

It covered checks such as:

  • Verify the owner and type of every account whose data is read.
  • Require signatures for authority actions.
  • Validate PDAs with the intended seeds and canonical bump.
  • Bind related accounts with constraints such as has_one.
  • Validate token-account mint and authority relationships.
  • Use checked arithmetic.
  • Test overflow, underflow and rounding.
  • Treat unchecked accounts as high-risk inputs.
  • Review the accounts and programs supplied to every CPI.
  • Write adversarial tests for instructions that move assets or change authority.
  • Check that attacks fail for the expected reason.
  • Add property tests for important invariants.
  • Fuzz high-value instruction sequences.
  • Preserve discovered failures as regression tests.

The exact checklist will vary from one program to another.

The useful change was replacing a vague final review with a repeatable process.

Instead of asking, “Does this look secure?”, we could ask:

Where is account ownership checked?

Which authority must sign?

How is the PDA verified?

What prevents two unrelated accounts from being used together?

What happens at the arithmetic boundary?

Which test attempts an unauthorised withdrawal?

Which properties must always remain true?

Those questions are easier to answer, review and revisit.

Explaining the checks made us understand them properly

The final two days focused on communicating what we had learned.

That matters in security work because it is possible to copy a constraint without understanding the threat it addresses.

Writing the checklist forced us to connect each protection to a concrete failure.

Signer<'info> matters because including an account in a transaction does not mean its owner approved the action.

Account<'info, T> matters because valid-looking data does not prove that the right program owns the account.

seeds and bump matter because the client can supply another account unless the program verifies the expected PDA.

has_one matters because two accounts can each be valid while having no valid relationship to one another.

Checked arithmetic matters because an attacker can choose values normal users never would.

Adversarial tests matter because successful transactions do not prove that malicious ones are rejected.

Property tests and fuzzing matter because we cannot write down every possible input and sequence.

A useful security explanation should say more than which Anchor attribute to add.

It should explain the assumption, how an attacker could violate it, and how the program prevents that from happening.

Day 84 asked us to reduce the longer checklist to a short X thread or LinkedIn post.

That meant choosing the checks that mattered most and backing them up with evidence from the week: a failing attack test, the forged configuration accepted by the insecure program, the same account rejected after the fix, or a counterexample found through generated testing.

The result was more useful than a general list of security advice.

It showed that we could identify an attack, reproduce it, fix it and prove that the fix worked.

What Arc 12 taught us

Arc 12 moved us from checking whether our programs worked to testing whether they could be abused.

By the end of the arc, we had learned how to:

  • Audit instructions for missing owner, signer and relationship checks.
  • Use Anchor types and constraints to enforce account validation.
  • Write adversarial tests and check that they fail for the intended reason.
  • Describe important rules as properties and invariants.
  • Use fuzzing to find cases we would not write by hand.
  • Turn discovered counterexamples into permanent regression tests.
  • Reproduce and fix an account-substitution exploit.
  • Build and share a reusable security checklist.

The main lesson was that the accounts supplied to an instruction are claims made by the transaction.

This is the authority.

This is the vault.

These accounts belong together.

This data is genuine.

This amount is safe to process.

The program has to verify each claim before acting on it.

Account ownership, signer checks, PDA derivation, relationship constraints and checked arithmetic provide that verification.

The tests show whether it holds up.

Adversarial tests cover the attacks we already understand.

Property testing and fuzzing search for the cases we missed.

Reproducing an exploit shows what one unchecked assumption can allow.

A secure program does not depend on the client doing the right thing.

It assumes someone will eventually try the wrong thing and is ready to reject it.

Revisit the Arc 12 challenges

Day 78: Audit your Anchor program for missing owner and signer checks

Day 79: Harden an insecure withdrawal instruction with Anchor constraints

Day 80: Write adversarial LiteSVM tests that try to break your program

Day 81: Use property-based testing and fuzzing to find unknown edge cases

Day 82: Reproduce and fix an account-substitution exploit

Day 83: Publish a practical Solana security checklist on DEV

Day 84: Turn the checklist into a social post or thread

Top comments (2)

Collapse
 
topstar_ai profile image
Luis Cruz

I appreciated how the article highlighted the importance of validating account ownership and authority in Solana programs, particularly through the use of Anchor constraints. The example of the intentionally insecure vault withdrawal and its subsequent fix was especially helpful in illustrating how these constraints can be used to enforce assumptions about account relationships. By moving validation to the accounts struct, as shown in the hardened instruction, developers can ensure that untrusted input is handled before it reaches the handler logic. This approach reminds me of the principle of "fail fast" in software design, where invalid input is rejected as early as possible to prevent downstream errors. Have you found any cases where Anchor's constraints are insufficient, requiring additional custom validation logic?

Collapse
 
grab_chess_309b9c06105b2d profile image
Grab Chess

Advanced testing and security really go hand in hand. The more thoroughly a system is tested especially for edge cases, vulnerabilities, and unexpected behaviour, the more resilient it becomes in production. It's much easier to prevent security issues than to fix them after deployment. Univik focuses on cloud, cybersecurity, and enterprise technology solutions, and their approach reflects how continuous testing and strong security practices are essential for building reliable digital systems.