Jekyll2025-06-25T02:53:44+00:00https://bensepanski.github.io/feed.xmlBenjamin SepanskiSSE at VeridiseBenjamin Sepanski[email protected]Mastering o1js on Mina: Four key strategies for secure development2025-02-19T00:00:00+00:002025-02-19T00:00:00+00:00https://bensepanski.github.io/posts/2025/02/19/Mastering-o1js-on-Mina-Four-key-strategies-for-secure-developmentFor the original (and properly formatted) publication, see Veridise’s post on medium.


If you’re building on the Mina blockchain, this blog post is a must-read.

After completing an extensive security audit of the o1js v1 library — spanning 39 person-weeks with 4 security analysts — we’ve gained a comprehensive understanding of the framework. We’re eager to share our insights with the community.

To help you develop securely with the o1js TypeScript library, we’ve distilled our findings into four topics. In this post, we highlight common pitfalls, share real-world examples of vulnerabilities, and provide actionable guidance to ensure your projects harness o1js effectively and securely.

Writing ZK Circuits with o1js and TypeScript

o1js brings cutting-edge technology to developers, enabling them to write ZK circuits in TypeScript and seamlessly deploy them to the Mina blockchain. While o1js abstracts much of the complexity of zero-knowledge proofs, it also introduces unique challenges and anti-patterns that developers must carefully navigate.

In this post, we dive into four illustrative examples:

  • Example #1: Trust the Types
  • Example #2: It’s still ZK under the hood: No conditional data flow!
  • Example #3. Account Updates: Mastering on-chain state management
  • Example #4: Don’t get DoS’ed: Actions & Reducers

Let’s dive in!

Example #1: Trust the Types

UInt64 type is a supposed to represent a value in the range [0, 2⁶⁴), i.e. 0, 1, 2, 3, …, up to 18,446,744,073,709,551,615

UInt64.assertLessThan() allows you to assert that a certain value x is less than another value

Provable.runAndCheck(() => {\
  // Introduce a UInt64 variable in the program\
  let x = Provable.witness(UInt64, () => {return new UInt64(100n);});\
  // Prove the variable is at most 2**48\
  x.assertLessThan(new UInt64(2n**48n));\
})

In the above program, all we prove is that x is some number less than 2⁴⁸. We set x to 100 in our generator, but anyone can change the witness generator. More specifically, anything inside the *Provable.witness* function is not proven! *x* can have any value, so long as it satisfies the constraints!

But where are constraints defined?

Provable.witness(UInt64, ...) adds constraints defined in UInt64.check() to ensure that verification won’t succeed unless x is in the range [0, 2⁶⁴)

x.assertLessThan(new UInt64(2n**48n)) then asserts that x is in the range [0, 2⁴⁸)

So far so good….

Now a clever user looks at this and might think: “Why check x is in [0, 2⁶⁴) if we are going to check x is in [0, 2⁴⁸) anyway? We can just do the second check!”

// BUG IN CODE: DO NOT USE\
Provable.runAndCheck(() => {\
  // Introduce an unconstrained variable in the program\
  let xAsField = Provable.exists(1, () => {return new UInt64(100n);});\
  // Unsafe cast it to a UInt64. This adds no constraints\
  let x = UInt64.Unsafe.from(xAsField);\
  // Prove the variable is at most 2**48\
  x.assertLessThan(new UInt64(2n**48n));\
})

While this looks innocuous, it is actually under-constrained! We can use values for x which are much larger than the input field.

Why?

  • UInt64.assertLessThan() assumes that we already know *x < 2**64*. Under the hood, it then asserts that 2⁴⁸ - x is in the range [0, 2⁶⁴). Remember that x is really a field element, so all arithmetic occurs modulo p for some large [p](https://electriccoin.co/blog/the-pasta-curves-for-halo-2-and-beyond/), and we can think of the range [0, p) as all possible values of x!

The implementation has the following cases:

  1. If x ∈ [0, 2⁴⁸), then 0 ≤ 2⁴⁸ — x < 2⁴⁸ < 2⁶⁴, so the check passes ✅
  2. If x ∈ [2⁴⁸, p + 2⁴⁸ — 2⁶⁴), then 2⁶⁴ ≤ 2⁴⁸ — x, so the check fails p + 2⁴⁸ — 2⁶⁴ ≈ p ≈ 2²⁵⁴ (note p + 2⁴⁸ — 2⁶⁴ ≈ p ≈ 2²⁵⁴ ) ✅ (just like we want)
  3. If x ∈ [p + 2⁴⁸ — 2⁶⁴, p) (i.e. the 2⁶⁴ — 2⁴⁸ — 1 largest elements in the field, much larger than 2⁴⁸), then the computation overflows all the way back into the range 0 ≤ 2⁴⁸ — x < 2⁴⁸! ❌ This is bad: the check will pass but x is much larger than 2⁴⁸

This is why we can’t cheat: we need the constraint x ∈ 2⁶⁴ from Provable.witness(UInt64, ...) to eliminate this 3rd “bad case”.

Example #2: It’s still ZK under the hood: No conditional data flow!

One common mistake we’ve seen across ecosystems comes from a limitation of ZK itself: the circuit’s control flow is entirely static.

No if/else

How can we do computations then? Conditional data flow.

Instead of

if case1 {
x = f(y)
}
else {
x = g(y)
}

We have to compute f(y), compute g(y), and then set

x = case1 ? f(y) : g(y)

Of course, in o1js this would look like

x = Provable.if(
case1,
MyProvableType, // tell o1js the type to use
x,
y
)

How can this cause issues? We often want to do computations which mightsucceed. A classical example is division: division-by-zero is undefined.

Suppose we are implementing a simple vault. The vault holds funds. You can deposit funds equaling 1% of the currently managed funds, and the vault will mint you a number of tokens equal to 1% of the currently existing tokens.

For example, imagine the supply of vault Tokens is 1000 Token, and the vault is holding 100 USDC. If I deposit 1 USDC, the vault will mint me 1 USDC * 1000 Token / 100 USDC = 10 Token. If I deposit 10 USDC, the vault will mint 10 USDC * 1000 Token / 100 USDC = 100 Token.

Ignoring decimals, this might be written simply as

amountToMint = totalSupply.mul(depositedAmount).div(managedFunds)

But what about the starting case? Suppose there are no funds and we are the first depositor. Commonly, we will set some fixed ratio: e.g. mint 1 token per initial USDC deposited. A first attempt at implementing this might be the below:

// BUG IN CODE: DO NOT USE\
amountToMint = Provable.if(\
  managedFunds.equals(new UInt64(0n)),\
  UInt64,                               // Output type\
  depositedAmount,\
  totalSupply.mul(depositedAmount).div(managedFunds)\
)

This looks like it will work: if no funds are currently managed, amountToMintis depositedAmount. Otherwise, we compute the ratio of tokens to managed funds.

The problem is simple: we always computetotalSupply.mul(depositedAmount).div(managedFunds), even when managedFunds is equal to zero. To guarantee its correctness, UInt64.div() will cause an assertion failure when the denominator is zero.

This may seem not so bad: we’ll catch it during testing. The problem is, it isn’t always so obvious. For example, what if the above contract starts with a non-zero amount of funds/total supply set up before the vault was opened to the public? Then this issue will only manifest if the managedFunds reaches 0, at which point it can never be deposited into again.

A more serious (but analogous) example could prevent the last user from withdrawing from the vault.

Example #3: Account Updates: Mastering on-chain state management

While o1js looks a lot like other popular smart contract languages, there are some important differences.

Each @method creates an AccountUpdate: A single object representing a change of on-chain state. These AccountUpdates have preconditions on the state to ensure valid access. For example, if I am decreasing a smart contract Mina balance by 10, the node must validate the account has at least 10 Mina—even if I have a proof that I executed the smart contract correctly.

Why? Remember that ZK is “state-less:” it has no hard drive, disk, or memory. All you prove is that, for some inputs which only you know, you did the correct computation.

When dealing with on-chain values, we need to prove that we used the actual on-chain values, and not just some random numbers! How? We output the “private-input” as “public preconditions” of the AccountUpdate. The node can then check the public preconditions

With state, we do this via the getAndRequireEquals() function. o1js will cause an error if you call get() without getAndRequireEquals(). It will now also call an error if you getAndRequireEquals() with two different values, due to an issue reported by Veridise (see #1712 Assert Preconditions are not already set when trying to set their values)

Let’s take a simple example.

// BUG IN CODE: DO NOT USE
export class Pool extends SmartContract {
@state(Boolean) paused = State();

@method function mint(amount: UInt64): UInt64 {
this.paused.get().assertFalse(“Pool paused!”);
// Minting logic
}
}

The above snippet is intended to prevent minting when the protocol is paused. In actuality, it just proves that the prover set paused to False in their local environment when generating the proof. To ensure that the network validates this assumption, the code should instead use getAndRequireEquals(). This way, the assumption paused = False is included in the AccountUpdate as part of the proof, forcing the network node to validate that the on-chain protocol is not paused.

export class Pool extends SmartContract {
@state(Boolean) paused = State();

@method function mint(amount: UInt64): UInt64 {
this.paused.getAndRequireEquals().assertFalse(“Pool paused!”);
// Minting logic
}
}

Example #4: Don’t get DoS’ed: Actions & Reducers

Preconditions can cause problems when concurrent accesses are occurring. Say you and I are both incrementing a counter. Our AccountUpdate will have two important parts:

  • A precondition that the current counter value is the old value x
  • A new value for the counter: x+1

If you and I both call this function when x = 3, we both have a precondition *x=3*! That means whichever one of us is executed second will have our AccountUpdate fail, since after the first person goes, x = 4 != 3

How can we fix this? We queue up actions.

Mina has a feature called actions & reducers. You can submit an “Action”, which gets put in a queue. Later, users can call a “reducer” which calls a function on those actions

Let’s look at an example taken from reducer-composite.ts:

class MaybeIncrement extends Struct({
isIncrement: Bool,
otherData: Field,
}) {}
const INCREMENT = { isIncrement: Bool(true), otherData: Field(0) };

class Counter extends SmartContract {
// the “reducer” field describes a type of action that we can dispatch, and reduce later
reducer = Reducer({ actionType: MaybeIncrement });

// on-chain version of our state. it will typically lag behind the
// version that’s implicitly represented by the list of actions
@state(Field) counter = State();\ // helper field to store the point in the action history that our on-chain state is at\ @state(Field) actionState = State();

@method async incrementCounter() {
this.reducer.dispatch(INCREMENT);
}
@method async dispatchData(data: Field) {
this.reducer.dispatch({ isIncrement: Bool(false), otherData: data });
}

@method async rollupIncrements() {
// get previous counter & actions hash, assert that they’re the same as on-chain values
let counter = this.counter.getAndRequireEquals();
let actionState = this.actionState.getAndRequireEquals();

// compute the new counter and hash from pending actions\
let pendingActions = this.reducer.getActions({\
  fromActionState: actionState,\
});

let newCounter = this.reducer.reduce(\
  pendingActions,\
  // state type\
  Field,\
  // function that says how to apply an action\
  (state: Field, action: MaybeIncrement) => {\
    return Provable.if(action.isIncrement, state.add(1), state);\
  },\
  counter,\
  { maxUpdatesWithActions: 10 }\
);

// update on-chain state\
this.counter.set(newCounter);\
this.actionState.set(pendingActions.hash);\   }\ }

In this code, incrementCounter() dispatches an action to the queue requesting an increment. dispatchData() adds a queue with some other unrelated data.

Anyone can process the entire queue by calling rollupIncrements(). This will go through the whole queue, incrementing once for each submitted (but unprocessed) request to increment.

Note:

  • The contract is responsible for managing the actionState field, which tracks “where we are in the queue.” In particular, this.actionState tracks what parts of the queue have been processed, while the Mina node automatically tracks what actions have been submitted via dispatch

Suppose that, instead of just incrementing by one, the user provided a number to add (e.g. an account balance change).

class MaybeIncrement extends Struct({
isIncrement: Bool,
otherData: Field,
amount: UInt64
}) {}

// function that says how to apply an action
(state: Field, action: MaybeIncrement) => {
return Provable.if(action.isIncrement, state.add(action.amount), state);
},

A malicious user could submit several large amounts, e.g.

{
isIncrement: new Bool(0),
otherData: new Field(0),
amount: new UInt64(UInt64.MAX),
}

Action submission will work smoothly, but this single action can permanently prevent all other actions from being processed!

Using this simplified example, the only way to process actions is in a single, large batch. Using more complex constructions like the batch reducer, or a custom rollup proof can get around this issue, but at the time of audit, only simple examples were made available for us to review.

If you decide to use the actions and reducer pattern, the reducer must be guaranteed to succeed once an action is submitted. This means that any arithmetic must be inspected carefully, actions must be strictly validated at submission, and must be canonicalized (see this PR #1759 Canonical representation of provable types, which was developed as a solution to an issue raised during the Veridise audit).

To see what other solutions the O(1) community is working on, check out this article from zkNoid.

Closing thoughts

o1js empowers developers to build cutting-edge zero-knowledge applications on the Mina blockchain, leveraging the simplicity of TypeScript to create and deploy ZK circuits.

However, this power comes with responsibilities. Developers must be vigilant about potential vulnerabilities, from respecting type constraints and avoiding under-constrained circuits to managing state updates effectively and mitigating concurrency risks.

We hope the examples and strategies shared in this blog provide a solid foundation for developing securely on Mina. The challenges and pitfalls of working with zero-knowledge circuits can be intricate, but with careful attention to detail and adherence to best practices, they can be navigated successfully.

Author

Ben Sepanski, Chief Security Officer at Veridise

Want to learn more about Veridise?

Twitter   LinkedIn   Github   Request Audit

[

Veridise

](https://medium.com/veridise?source=post_page—post_publication_info–fff3a3f4f6d1—————————————)

[

Published in Veridise

](https://medium.com/veridise?source=post_page—post_publication_info–fff3a3f4f6d1—————————————)

62 Followers

-Last published 4 days ago

Our mission in to harden blockchain security with formal methods. We write about blockchain security, zero-knowledge proofs, and our bug discoveries.

Follow

[

Veridise

](https://medium.com/@veridise?source=post_page—post_author_info–fff3a3f4f6d1—————————————)

[

Written by Veridise

](https://medium.com/@veridise?source=post_page—post_author_info–fff3a3f4f6d1—————————————)

277 Followers

-3 Following

Hardening blockchain security with formal methods. We write about blockchain & zero-knowledge proof security. Contact us for industry-leading security audits.

Follow

No responses yet

[

What are your thoughts?

](https://medium.com/m/signin?operation=register&redirect=https%3A%2F%2Fmedium.com%2Fveridise%2Fmastering-o1js-on-mina-four-key-strategies-for-secure-development-fff3a3f4f6d1&source=—post_responses–fff3a3f4f6d1———————respond_sidebar——————)

Cancel

Respond

Respond

Also publish to my profile

More from Veridise and Veridise

Zero Knowledge for Dummies: Introduction to ZK Proofs

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-0———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

In

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-0———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

by

[

Veridise

](https://medium.com/@veridise?source=post_page—author_recirc–fff3a3f4f6d1—-0———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

[

Zero Knowledge for Dummies: Introduction to ZK Proofs

Do you have zero knowledge about zero knowledge? Do you want to learn more about it? You’re in the right place and we have cookies.

](https://medium.com/veridise/zero-knowledge-for-dummies-introduction-to-zk-proofs-29e3fe9604f1?source=post_page—author_recirc–fff3a3f4f6d1—-0———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

Aug 24, 2023

[

150

1

](https://medium.com/veridise/zero-knowledge-for-dummies-introduction-to-zk-proofs-29e3fe9604f1?source=post_page—author_recirc–fff3a3f4f6d1—-0———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

Zero Knowledge for Dummies: Demystifying ZK Circuits

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-1———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

In

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-1———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

by

[

Veridise

](https://medium.com/@veridise?source=post_page—author_recirc–fff3a3f4f6d1—-1———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

[

Zero Knowledge for Dummies: Demystifying ZK Circuits

ZK circuits are the “magic tools” that enable ZK proofs

](https://medium.com/veridise/zero-knowledge-for-dummies-demystifying-zk-circuits-c140a64c6ed3?source=post_page—author_recirc–fff3a3f4f6d1—-1———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

Jan 19, 2024

[

27

](https://medium.com/veridise/zero-knowledge-for-dummies-demystifying-zk-circuits-c140a64c6ed3?source=post_page—author_recirc–fff3a3f4f6d1—-1———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

Highlights from the Veridise o1js v1 audit: Three zero-knowledge security bugs explained

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-2———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

In

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-2———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

by

[

Veridise

](https://medium.com/@veridise?source=post_page—author_recirc–fff3a3f4f6d1—-2———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

[

Highlights from the Veridise o1js v1 audit: Three zero-knowledge security bugs explained

In 2024, the Veridise team conducted a comprehensive security audit of o1js, a crucial TypeScript library that powers zero-knowledge…

](https://medium.com/veridise/highlights-from-the-veridise-o1js-v1-audit-three-zero-knowledge-security-bugs-explained-2f5708f13681?source=post_page—author_recirc–fff3a3f4f6d1—-2———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

Feb 3

Recursive SNARKs and Incrementally Verifiable Computation (IVC)

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-3———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

In

[

Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—-3———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

by

[

Veridise

](https://medium.com/@veridise?source=post_page—author_recirc–fff3a3f4f6d1—-3———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

[

Recursive SNARKs and Incrementally Verifiable Computation (IVC)

PART I: Recursive SNARKs and Incrementally Verifiable Computation

](https://medium.com/veridise/introduction-to-nova-and-zk-folding-schemes-4ef717574484?source=post_page—author_recirc–fff3a3f4f6d1—-3———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

Jul 27, 2023

[

70

](https://medium.com/veridise/introduction-to-nova-and-zk-folding-schemes-4ef717574484?source=post_page—author_recirc–fff3a3f4f6d1—-3———————d84e01f0_b292_493f_bbea_aac71074a8f8————–)

[

See all from Veridise

](https://medium.com/@veridise?source=post_page—author_recirc–fff3a3f4f6d1—————————————)

[

See all from Veridise

](https://medium.com/veridise?source=post_page—author_recirc–fff3a3f4f6d1—————————————)

Animation in react native

[

Ankit

](https://medium.com/@mohantaankit2002?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Ankit

](https://medium.com/@mohantaankit2002?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Making React Native Animations Buttery Smooth on Budget Phones

Got a beautiful animation that turns into a slideshow on low-end devices? Let’s fix that without compromising your app’s wow factor.

](https://medium.com/@mohantaankit2002/making-react-native-animations-buttery-smooth-on-budget-phones-f6ff3d4215bd?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

Feb 15

Mastering React Native in 2025: Real Talk from a Developer's Perspective

[

Stackademic

](https://medium.com/stackademic?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

In

[

Stackademic

](https://medium.com/stackademic?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

by

[

Abvhishek kumar

](https://medium.com/@abvhishekkumaar?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Mastering React Native in 2025: Real Talk from a Developer’s Perspective

React Native has been a go-to choice for cross-platform mobile development for a while now, and in 2025, it’s more powerful and flexible…

](https://medium.com/stackademic/mastering-react-native-in-2025-real-talk-from-a-developers-perspective-96aa64910a20?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

6d ago

[

2

1

](https://medium.com/stackademic/mastering-react-native-in-2025-real-talk-from-a-developers-perspective-96aa64910a20?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

Lists

[

An illustration of a Featured story push notification for a publication follower.

Staff picks

817 stories-1632 saves

](https://medium.com/@MediumStaff/list/staff-picks-c7bc6e1ee00f?source=post_page—read_next_recirc–fff3a3f4f6d1—————————————)

[

Stories to Help You Level-Up at Work

19 stories-942 saves

](https://medium.com/@MediumStaff/list/stories-to-help-you-levelup-at-work-faca18b0622f?source=post_page—read_next_recirc–fff3a3f4f6d1—————————————)

[

Self-Improvement 101

20 stories-3313 saves

](https://medium.com/@MediumForTeams/list/selfimprovement-101-3c62b6cb0526?source=post_page—read_next_recirc–fff3a3f4f6d1—————————————)

[

Productivity 101

20 stories-2787 saves

](https://medium.com/@MediumForTeams/list/productivity-101-f09f1aaf38cd?source=post_page—read_next_recirc–fff3a3f4f6d1—————————————)

A laptop, 2 phones, notebook, and other things

[

Andrew Zuo

](https://medium.com/@impure?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Andrew Zuo

](https://medium.com/@impure?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Go Is A Poorly Designed Language, Actually

I read an article titled Go is a Well-Designed Language, Actually. Hard disagree. I originally liked Go because it did simplify things. I…

](https://medium.com/@impure/go-is-a-poorly-designed-language-actually-a8ec508fc2ed?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

4d ago

[

263

24

](https://medium.com/@impure/go-is-a-poorly-designed-language-actually-a8ec508fc2ed?source=post_page—read_next_recirc–fff3a3f4f6d1—-0———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

MacOS: Building the Perfect Development Machine in 2025

[

Level Up Coding

](https://medium.com/gitconnected?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

In

[

Level Up Coding

](https://medium.com/gitconnected?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

by

[

Promise Chukwuenyem

](https://medium.com/@promisepreston?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

MacOS: Building the Perfect Development Machine in 2025

How I Transformed My MacBook Into a Development Powerhouse

](https://medium.com/gitconnected/from-linux-to-mac-building-the-perfect-development-machine-in-2025-14db582f239f?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

Jan 13

[

351

3

](https://medium.com/gitconnected/from-linux-to-mac-building-the-perfect-development-machine-in-2025-14db582f239f?source=post_page—read_next_recirc–fff3a3f4f6d1—-1———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

Highlights from the Veridise o1js v1 audit: Three zero-knowledge security bugs explained

[

Veridise

](https://medium.com/veridise?source=post_page—read_next_recirc–fff3a3f4f6d1—-2———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

In

[

Veridise

](https://medium.com/veridise?source=post_page—read_next_recirc–fff3a3f4f6d1—-2———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

by

[

Veridise

](https://medium.com/@veridise?source=post_page—read_next_recirc–fff3a3f4f6d1—-2———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Highlights from the Veridise o1js v1 audit: Three zero-knowledge security bugs explained

In 2024, the Veridise team conducted a comprehensive security audit of o1js, a crucial TypeScript library that powers zero-knowledge…

](https://medium.com/veridise/highlights-from-the-veridise-o1js-v1-audit-three-zero-knowledge-security-bugs-explained-2f5708f13681?source=post_page—read_next_recirc–fff3a3f4f6d1—-2———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

Feb 3

"Bitcoin is Going to Zero Within a Decade."

[

Crypto with Lorenzo

](https://medium.com/@cryptowithlorenzo?source=post_page—read_next_recirc–fff3a3f4f6d1—-3———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

Crypto with Lorenzo

](https://medium.com/@cryptowithlorenzo?source=post_page—read_next_recirc–fff3a3f4f6d1—-3———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

“Bitcoin is Going to Zero Within a Decade.”

Breaking down the arguments made by an economics expert.

](https://medium.com/@cryptowithlorenzo/bitcoin-is-going-to-zero-5562122f5481?source=post_page—read_next_recirc–fff3a3f4f6d1—-3———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

Feb 5

[

390

36

](https://medium.com/@cryptowithlorenzo/bitcoin-is-going-to-zero-5562122f5481?source=post_page—read_next_recirc–fff3a3f4f6d1—-3———————ebb31a75_5f5f_46b7_8d3d_a06f39dfabc3————–)

[

See more recommendations

](https://medium.com/?source=post_page—read_next_recirc–fff3a3f4f6d1—————————————)

[

Help

](https://help.medium.com/hc/en-us?source=post_page—–fff3a3f4f6d1—————————————)

[

Status

](https://medium.statuspage.io/?source=post_page—–fff3a3f4f6d1—————————————)

[

About

](https://medium.com/about?autoplay=1&source=post_page—–fff3a3f4f6d1—————————————)

[

Careers

](https://medium.com/jobs-at-medium/work-at-medium-959d1a85284e?source=post_page—–fff3a3f4f6d1—————————————)

[

Press

](mailto:[email protected])

[

Blog

](https://blog.medium.com/?source=post_page—–fff3a3f4f6d1—————————————)

[

Privacy

](https://policy.medium.com/medium-privacy-policy-f03bf92035c9?source=post_page—–fff3a3f4f6d1—————————————)

[

Terms

](https://policy.medium.com/medium-terms-of-service-9db0094a1e0f?source=post_page—–fff3a3f4f6d1—————————————)

[

Text to speech

](https://speechify.com/medium?source=post_page—–fff3a3f4f6d1—————————————)

[

Teams

](https://medium.com/business?source=post_page—–fff3a3f4f6d1—————————————)ridise%2Fhighlights-from-the-veridise-o1js-v1-audit-three-zero-knowledge-security-bugs-explained-2f5708f13681&source=post_page—top_nav_layout_nav———————–global_nav——————)

Highlights from the Veridise o1js v1 audit: Three zero-knowledge security bugs explained

[

Veridise

](https://medium.com/@veridise?source=post_page—byline–2f5708f13681—————————————)

[

Veridise

](https://medium.com/veridise?source=post_page—byline–2f5708f13681—————————————)

Veridise

Follow

Published in

[

Veridise

](https://medium.com/veridise?source=post_page—byline–2f5708f13681—————————————)

10 min read

Feb 3, 2025

In 2024, the Veridise team conducted a comprehensive security audit of o1js, a crucial TypeScript library that powers zero-knowledge application development on the Mina blockchain.

The security assessment spanned 39 person-weeks, with four security analysts working over a period of 13 weeks. The audit strategy combined tool-assisted analysis of the source code by Veridise engineers with extensive manual, line-by-line code reviews.

In this blog post, we’ll dive into three of the most intriguing vulnerabilities uncovered during our audit. These issues are particularly noteworthy because they span different layers of the cryptographic stack, ranging from low-level field arithmetic to high-level protocol design. What unites them all is their relation to range checks.

To make the findings easier to follow and understand, we’ve simplified the bugs into illustrative examples. Full reporting on the actual vulnerabilities can be found in the full audit report.

Working together with o1Labs

Our collaboration with o1Labs was both productive and engaging. We had weekly meetings and maintained constant communication via Slack.

Most of our interactions were with Gregor and Florian, who were highly active and deeply involved. They worked closely with us to enhance our understanding of the system and even identified some of the bugs independently, working in parallel with our team.

They frequently shared detailed insights through long Slack threads, and were responsive to any queries from our auditors. Their deep knowledge of the codebase allowed them to efficiently guide us to the areas our auditors needed to focus on.

A highlight of the collaboration was Gregor’s incredibly thorough writeups on optimizations. These were invaluable in helping us navigate and comprehend complex circuits, such as emulated field arithmetic and multi-scalar multiplication. His detailed explanations were helpful in our ability to follow and address these intricate components.

Three vulnerabilities Veridise fixed in o1js

1) Importance of type checks: range validation in zero-knowledge circuits

Among the high-severity vulnerabilities we discovered in o1js was a subtle but dangerous flaw in how circuits which validate ECDSA signatures or manipulate foreign curve points are verified. This bug (V-O1J-VUL-006) highlights how missing range checks can undermine the security of cryptographic protocols.

As an overview, in o1js, data is often decomposed into smaller components for processing. A common example is bit decomposition, where a number is broken down into its binary representation:

For instance:

  • The number 3 can be written as 1 + 1 * 2, which is encoded as [1, 1].
  • The number 7 can be written as 1 + 1 * 2 + 1 * 4, encoded as [1, 1, 1].

This same decomposition concept can be applied to larger bases. For example, in o1js, instead of base 2, you might use 2^88:

  • [1, 1, 1] in this context represents:
  • 1 + 1 * 2^88 + 1 * (2^88)^2 = 1 + 1 * 2^88 + 1 * 2^176.

The problem:

The decomposition is only well-defined (or unique) if each component in the representation remains within a specified range.

Bit decomposition example

In bit decomposition, each entry must be either 0 or 1. If this condition is violated, ambiguities arise.

For instance, 7 could be decomposed in multiple ways:[1, 3, 0] or [1, 1, 1].

This happens because you can “borrow” from higher components. Example:

  • 1 + 1 * 2 + 1 * 4 can also be expressed as:
  • 1 + (1 + 2) * 2 + (2 - 2) * 4 = 1 + 3 * 2.

Larger Bases (e.g., 2^88)

For larger bases like 2^88, each entry must satisfy:0 ≤ entry < 2^88.

Without this constraint, similar ambiguity occurs: You can “add” or “subtract” between components to create alternate decompositions.

Specific bug details

In this case, a custom type was represented using three limbs, each of size 2^88.

However, there was no check to ensure that the limbs were actually within the range [0, 2^88 - 1].

Impact and the fix:

An attacker can manipulate the values of these limbs and carefully choose values that overflow during subsequent computations.

This creates opportunities for cryptographic exploits and undermines the integrity of the protocol.

The root cause of this vulnerability — and similar ones — is missing range checks. Ensuring proper type and range validation is critical to maintaining the security and correctness of zero-knowledge circuits.

2) Hash collisions in merkle map key-to-index mapping

The basic idea of bug V-O1J-VUL-002 is that a mapping is being stored in a Merkle tree with more leaves than keys in the map.

The problem

keys are limited to 254 bits, so they lie within the range [0, 2**254). However, the Merkle tree has more index es than there are unique key s!

This means some index es in the tree must map to the same **key**.

In fact, it is straightforward to determine which indexes share the same key—there are trillions of possibilities.

Simplified example & exploit

Suppose a key is an address and a value indicates whether the address is blacklisted (true) or not (false).

A single key might correspond to two distinct indexes in the Merkle tree: At one index, the value stored is true. At another index, the value stored is false (an edge case overlooked by developers).

The attacker can choose which index to use, enabling them to exploit the system. Naturally, the attacker will select the index with the value advantageous to them.

To summarize, the core issue is that instead of a one-to-one relationship between keys and indexes, some keys correspond to multiple indexes. This allows attackers to exploit the ambiguity and choose the mapping that benefits them.

Impact and the fix

As shown in the above proof of concept, a user may prove that some entries of a MerkleMap are empty, even after they have been set. This can have critical consequences, such as user could prove their address is not in a blacklist, or that a certain nullifier is not in a Merkle tree.

We recommended a range-check on to prevent this overflow.

While this high-level overview omits some details, it captures the essence of the vulnerability. Full description of the bug can be found in the audit report, PDF page 15.

3) The hidden dangers of hash inversion in ZK circuits

The core concept in the first bug (V-O1J-VUL-001, PDF page 15) revolves around multiscalar multiplication and a bug in a compress-select-decompressprocess. This bug would have enabled attackers to have control over the output of the multi-scalar computation.

In this blog post, we’re giving a simplified example of the bug for easier comprehension and readability. The actual bug specific to o1js can be studied in the audit report.

What are multiscalar multiplications?

At a high level, multiscalar multiplication essentially means performing multiple multiplications and adding the results together. However, instead of multiplying numbers, in this context one is usually dealing with cryptographic constructs called elliptic curve points.

Multiscalar multiplication (MSM) is usually implemented as a specialized operation designed to make common calculations faster and more efficient.

Rather than describe the full context, this blog will focus on a particular step in the multi-scalar multiplication algorithm. At this step, o1js needs to choose between two possible values for an array. For details on how this fits into the larger MSM algorithm, see here. For the purposes of this blog, just know that if an attacker can control which of the two possible arrays is chosen, they can potentially alter the output of standard cryptographic algorithms like ECDSA.

Challenges in ZK Circuits: there are no if-else statements

ZK circuits lack traditional control flow structures like if-else statements. Instead, both options (e.g., left and right) must be computed, and the desired option is selected by multiplying it by 1, while the undesired option is multiplied by 0.

This approach can be inefficient, especially when the options involve large datasets. For example, if Option 1 sets 10 values (e.g., 1, 2, 3, ... 10) and Option 2 sets another 10 values (e.g., 11, 12, 13, ... 20), the circuit essentially computes both options in full and pays the computational cost for each, even though only one is used. This inefficiency is the problem o1js aims to optimize.

Optimization Approach

  1. Compression: Instead of working with all 10 values directly, the values are first hashed together. This creates a single “commitment” value representing the 10 values.
  2. Decision: Both the left and right options are compressed into single hashes. The algorithm then decides between these compressed options.
  3. Decompression: After the decision is made, the chosen compressed value is decompressed back into the original 10 values.

By compressing the data first, the circuit avoids the overhead of making the decision 10 times. Instead, it only makes the decision once, based on the compressed hashes.

The core issue — Compress-select-decompress process

The key vulnerability lies in the compress-select-decompress process. In ZK circuits, decompression must reliably produce the exact original values from the compressed hash. Any mismatch here could lead to attackers gaining control of the execution.

Why this can fail

Compression functions typically handle data as 1s and 0s. These binary representations might correspond to scalars, field elements, or booleans, or other types.

If the same binary data (1s and 0s) can be decoded into multiple possible values, the decompression process might yield incorrect or unexpected results. This ambiguity creates a potential exploit.

Compression is not injective

In simple terms, the issue in the o1js code arises from an optimization routine that is not injective. Injective means that the decoding process can produce multiple valid solutions, leading to potential vulnerabilities.

Here’s how the original optimization process works:

  1. Compression:
  • The values [a0, a1] are compressed into a single hash: hash(encode([a0, a1])).
  • Similarly, [b0, b1] is compressed: hash(encode([b0, b1])).

2. “Switch” step:

  • The algorithm selects one of the two compressed options and assigns it to choice.

3. Decompression:

  • The algorithm verifies that the decompressed result matches the chosen hash:assert(hash(encode([r0, r1])) == choice).

4. Result Extraction:

  • After decompression, [r0, r1] are obtained as the resulting values.

The encoding process lacks the necessary property to guarantee that decoding and re-encoding will always yield the same result. In other words:

decode(encode([r0, r1])) == choice

does not consistently hold true. This means that the same encoded value can be decoded into multiple different outputs, introducing ambiguity.

Such behavior creates a vulnerability, as the system may fail to reliably match the original values after decompression. This non-injective encoding undermines the security and correctness of the algorithm.

Let’s understand this with an example encoding function

Consider an array of length 2, where each value is a boolean (0 or 1). Possible values for the array are:[0, 0][0, 1][1, 0], or [1, 1]

We define the following:

  • Option 0: [a0, a1]
  • Option 1: [b0, b1]
  • Selector variable: s, which can be 0 or 1
  • Goal: Store the correct value into the result [r0, r1]

The slow way

For each element in the result:

Compute r0 using:r0 := (1-s) * a0 + s * b0

Why does this work?

  • When s = 0:r0 := (1 - 0) * a0 + 0 * b0 = a0
  • When s = 1:r0 := 0 * a0 + 1 * b0 = b0

Similarly, compute r1 using:

  • r1 := (1-s) * a1 + s * b1

This computation is sometimes called “switching” between a and b.

This approach works but can be slow, as it requires repeating the computation for each element. With an array of length two, this is not so bad. But as the arrays get longer and longer, the repeated computation can take a toll.

Bad optimization

Instead of directly switching, the optimization uses an encoding function:

  • encode([a0, a1]) = a0 + 2 * a1
  • encode([b0, b1]) = b0 + 2 * b1

This encoding maps the arrays uniquely:

  • [0, 0] → 0 + 2 * 0 = 0
  • [0, 1] → 0 + 2 * 1 = 2
  • [1, 0] → 1 + 2 * 0 = 1
  • [1, 1] → 1 + 2 * 1 = 3

This seems fine so far, as the mappings are unique.

Optimization process

  1. Compression: Compress both options into single values:

cA := a0 + 2 * a1
cB := b0 + 2 * b1

  1. Switch: Use the selector s to choose between the compressed values:

cR := (1-s) * cA + s * cB

  1. Decompression: Generate r0, r1 from the witness generator and add a constraint to ensure the decompressed values match:

r0 + 2 * r1 = cR

Example exploit

The issue lies in the decompression step. The witness generator is not constrained to produce valid boolean values (0 or 1) for r0 and r1.

This means that instead of [r0, r1] being restricted to [0, 1], they can take arbitrary values.

An attacker could choose r0 = 1,000,000 and r1 = -500,000. Let’s compute the compressed value:

cR = r0 + 2 * r1 = 1,000,000 + 2 * (-500,000) = 1,000,000 - 1,000,000 = 0

This satisfies the constraint r0 + 2 * r1 = cR, but clearly, the values [r0, r1] do not represent valid boolean values.

Root cause

During compression, the original arrays [a0, a1] and [b0, b1] were constrained to boolean values (0 or 1).

However, during decompression, the original code failed to enforce these constraints, allowing the witness generator to produce invalid values.

This lack of constraints makes the encoding non-injective, meaning the decompressed values can correspond to multiple possible outputs, enabling attackers to exploit the system.

Impact and the fix

The vulnerability arises because the decompression step lacks proper constraints, breaking the injective property of the encoding-decoding process. To fix this, the system must enforce that r0 and r1 remain within their valid range (0 or 1) during decompression.

Final remarks

o1js has a very active set of contributors and is very interested in security. They have worked hard to bring ZK technology to TypeScript developers, and overcome a set of unique challenges in bringing smart contract logic to the Mina blockchain.

We found out it remarkably straightforward to prototype proof-of-concept applications using the TypeScript library. This ease of use extends to identifying under-constrained bugs and testing circuits in ways that developers might not have anticipated.

If you’re planning to develop dapps on the Mina blockchain using TypeScript, stay tuned — our upcoming blog post will provide insights and best practices for secure development, drawing from our auditing experience.

Full audit report

Download the full o1js security audit report here.

Author:
Ben Sepanski, Chief Security Officer at Veridise

Editor: Mikko Ikola, VP of Marketing

]]>
Benjamin Sepanski[email protected]
Highlights from the Veridise o1js v1 audit: Three zero-knowledge security bugs explained2025-02-03T00:00:00+00:002025-02-03T00:00:00+00:00https://bensepanski.github.io/posts/2025/02/03/Highlights-from-the-Veridise-o1js-v1-auditFor the original (and properly formatted) publication, see Veridise’s post on medium.


In 2024, the Veridise team conducted a comprehensive security audit of o1js, a crucial TypeScript library that powers zero-knowledge application development on the Mina blockchain.

The security assessment spanned 39 person-weeks, with four security analysts working over a period of 13 weeks. The audit strategy combined tool-assisted analysis of the source code by Veridise engineers with extensive manual, line-by-line code reviews.

In this blog post, we’ll dive into three of the most intriguing vulnerabilities uncovered during our audit. These issues are particularly noteworthy because they span different layers of the cryptographic stack, ranging from low-level field arithmetic to high-level protocol design. What unites them all is their relation to range checks.

To make the findings easier to follow and understand, we’ve simplified the bugs into illustrative examples. Full reporting on the actual vulnerabilities can be found in the full audit report.

Working together with o1Labs

Our collaboration with o1Labs was both productive and engaging. We had weekly meetings and maintained constant communication via Slack.

Most of our interactions were with Gregor and Florian, who were highly active and deeply involved. They worked closely with us to enhance our understanding of the system and even identified some of the bugs independently, working in parallel with our team.

They frequently shared detailed insights through long Slack threads, and were responsive to any queries from our auditors. Their deep knowledge of the codebase allowed them to efficiently guide us to the areas our auditors needed to focus on.

A highlight of the collaboration was Gregor’s incredibly thorough writeups on optimizations. These were invaluable in helping us navigate and comprehend complex circuits, such as emulated field arithmetic and multi-scalar multiplication. His detailed explanations were helpful in our ability to follow and address these intricate components.

Three vulnerabilities Veridise fixed in o1js

1) Importance of type checks: range validation in zero-knowledge circuits

Among the high-severity vulnerabilities we discovered in o1js was a subtle but dangerous flaw in how circuits which validate ECDSA signatures or manipulate foreign curve points are verified. This bug (V-O1J-VUL-006) highlights how missing range checks can undermine the security of cryptographic protocols.

As an overview, in o1js, data is often decomposed into smaller components for processing. A common example is bit decomposition, where a number is broken down into its binary representation:

For instance:

  • The number 3 can be written as 1 + 1 * 2, which is encoded as [1, 1].
  • The number 7 can be written as 1 + 1 * 2 + 1 * 4, encoded as [1, 1, 1].

This same decomposition concept can be applied to larger bases. For example, in o1js, instead of base 2, you might use 2^88:

  • [1, 1, 1] in this context represents:
  • 1 + 1 * 2^88 + 1 * (2^88)^2 = 1 + 1 * 2^88 + 1 * 2^176.

The problem:

The decomposition is only well-defined (or unique) if each component in the representation remains within a specified range.

Bit decomposition example

In bit decomposition, each entry must be either 0 or 1. If this condition is violated, ambiguities arise.

For instance, 7 could be decomposed in multiple ways:[1, 3, 0] or [1, 1, 1].

This happens because you can “borrow” from higher components. Example:

  • 1 + 1 * 2 + 1 * 4 can also be expressed as:
  • 1 + (1 + 2) * 2 + (2 - 2) * 4 = 1 + 3 * 2.

Larger Bases (e.g., 2^88)

For larger bases like 2^88, each entry must satisfy:0 ≤ entry < 2^88.

Without this constraint, similar ambiguity occurs: You can “add” or “subtract” between components to create alternate decompositions.

Specific bug details

In this case, a custom type was represented using three limbs, each of size 2^88.

However, there was no check to ensure that the limbs were actually within the range [0, 2^88 - 1].

Impact and the fix:

An attacker can manipulate the values of these limbs and carefully choose values that overflow during subsequent computations.

This creates opportunities for cryptographic exploits and undermines the integrity of the protocol.

The root cause of this vulnerability — and similar ones — is missing range checks. Ensuring proper type and range validation is critical to maintaining the security and correctness of zero-knowledge circuits.

2) Hash collisions in merkle map key-to-index mapping

The basic idea of bug V-O1J-VUL-002 is that a mapping is being stored in a Merkle tree with more leaves than keys in the map.

The problem

keys are limited to 254 bits, so they lie within the range [0, 2**254). However, the Merkle tree has more index es than there are unique key s!

This means some index es in the tree must map to the same **key**.

In fact, it is straightforward to determine which indexes share the same key—there are trillions of possibilities.

Simplified example & exploit

Suppose a key is an address and a value indicates whether the address is blacklisted (true) or not (false).

A single key might correspond to two distinct indexes in the Merkle tree: At one index, the value stored is true. At another index, the value stored is false (an edge case overlooked by developers).

The attacker can choose which index to use, enabling them to exploit the system. Naturally, the attacker will select the index with the value advantageous to them.

To summarize, the core issue is that instead of a one-to-one relationship between keys and indexes, some keys correspond to multiple indexes. This allows attackers to exploit the ambiguity and choose the mapping that benefits them.

Impact and the fix

As shown in the above proof of concept, a user may prove that some entries of a MerkleMap are empty, even after they have been set. This can have critical consequences, such as user could prove their address is not in a blacklist, or that a certain nullifier is not in a Merkle tree.

We recommended a range-check on to prevent this overflow.

While this high-level overview omits some details, it captures the essence of the vulnerability. Full description of the bug can be found in the audit report, PDF page 15.

3) The hidden dangers of hash inversion in ZK circuits

The core concept in the first bug (V-O1J-VUL-001, PDF page 15) revolves around multiscalar multiplication and a bug in a compress-select-decompressprocess. This bug would have enabled attackers to have control over the output of the multi-scalar computation.

In this blog post, we’re giving a simplified example of the bug for easier comprehension and readability. The actual bug specific to o1js can be studied in the audit report.

What are multiscalar multiplications?

At a high level, multiscalar multiplication essentially means performing multiple multiplications and adding the results together. However, instead of multiplying numbers, in this context one is usually dealing with cryptographic constructs called elliptic curve points.

Multiscalar multiplication (MSM) is usually implemented as a specialized operation designed to make common calculations faster and more efficient.

Rather than describe the full context, this blog will focus on a particular step in the multi-scalar multiplication algorithm. At this step, o1js needs to choose between two possible values for an array. For details on how this fits into the larger MSM algorithm, see here. For the purposes of this blog, just know that if an attacker can control which of the two possible arrays is chosen, they can potentially alter the output of standard cryptographic algorithms like ECDSA.

Challenges in ZK Circuits: there are no if-else statements

ZK circuits lack traditional control flow structures like if-else statements. Instead, both options (e.g., left and right) must be computed, and the desired option is selected by multiplying it by 1, while the undesired option is multiplied by 0.

This approach can be inefficient, especially when the options involve large datasets. For example, if Option 1 sets 10 values (e.g., 1, 2, 3, ... 10) and Option 2 sets another 10 values (e.g., 11, 12, 13, ... 20), the circuit essentially computes both options in full and pays the computational cost for each, even though only one is used. This inefficiency is the problem o1js aims to optimize.

Optimization Approach

  1. Compression: Instead of working with all 10 values directly, the values are first hashed together. This creates a single “commitment” value representing the 10 values.
  2. Decision: Both the left and right options are compressed into single hashes. The algorithm then decides between these compressed options.
  3. Decompression: After the decision is made, the chosen compressed value is decompressed back into the original 10 values.

By compressing the data first, the circuit avoids the overhead of making the decision 10 times. Instead, it only makes the decision once, based on the compressed hashes.

The core issue — Compress-select-decompress process

The key vulnerability lies in the compress-select-decompress process. In ZK circuits, decompression must reliably produce the exact original values from the compressed hash. Any mismatch here could lead to attackers gaining control of the execution.

Why this can fail

Compression functions typically handle data as 1s and 0s. These binary representations might correspond to scalars, field elements, or booleans, or other types.

If the same binary data (1s and 0s) can be decoded into multiple possible values, the decompression process might yield incorrect or unexpected results. This ambiguity creates a potential exploit.

Compression is not injective

In simple terms, the issue in the o1js code arises from an optimization routine that is not injective. Injective means that the decoding process can produce multiple valid solutions, leading to potential vulnerabilities.

Here’s how the original optimization process works:

  1. Compression:
  • The values [a0, a1] are compressed into a single hash: hash(encode([a0, a1])).
  • Similarly, [b0, b1] is compressed: hash(encode([b0, b1])).

2. “Switch” step:

  • The algorithm selects one of the two compressed options and assigns it to choice.

3. Decompression:

  • The algorithm verifies that the decompressed result matches the chosen hash:assert(hash(encode([r0, r1])) == choice).

4. Result Extraction:

  • After decompression, [r0, r1] are obtained as the resulting values.

The encoding process lacks the necessary property to guarantee that decoding and re-encoding will always yield the same result. In other words:

decode(encode([r0, r1])) == choice

does not consistently hold true. This means that the same encoded value can be decoded into multiple different outputs, introducing ambiguity.

Such behavior creates a vulnerability, as the system may fail to reliably match the original values after decompression. This non-injective encoding undermines the security and correctness of the algorithm.

Let’s understand this with an example encoding function

Consider an array of length 2, where each value is a boolean (0 or 1). Possible values for the array are:[0, 0][0, 1][1, 0], or [1, 1]

We define the following:

  • Option 0: [a0, a1]
  • Option 1: [b0, b1]
  • Selector variable: s, which can be 0 or 1
  • Goal: Store the correct value into the result [r0, r1]

The slow way

For each element in the result:

Compute r0 using:r0 := (1-s) * a0 + s * b0

Why does this work?

  • When s = 0:r0 := (1 - 0) * a0 + 0 * b0 = a0
  • When s = 1:r0 := 0 * a0 + 1 * b0 = b0

Similarly, compute r1 using:

  • r1 := (1-s) * a1 + s * b1

This computation is sometimes called “switching” between a and b.

This approach works but can be slow, as it requires repeating the computation for each element. With an array of length two, this is not so bad. But as the arrays get longer and longer, the repeated computation can take a toll.

Bad optimization

Instead of directly switching, the optimization uses an encoding function:

  • encode([a0, a1]) = a0 + 2 * a1
  • encode([b0, b1]) = b0 + 2 * b1

This encoding maps the arrays uniquely:

  • [0, 0] → 0 + 2 * 0 = 0
  • [0, 1] → 0 + 2 * 1 = 2
  • [1, 0] → 1 + 2 * 0 = 1
  • [1, 1] → 1 + 2 * 1 = 3

This seems fine so far, as the mappings are unique.

Optimization process

  1. Compression: Compress both options into single values:

cA := a0 + 2 * a1
cB := b0 + 2 * b1

  1. Switch: Use the selector s to choose between the compressed values:

cR := (1-s) * cA + s * cB

  1. Decompression: Generate r0, r1 from the witness generator and add a constraint to ensure the decompressed values match:

r0 + 2 * r1 = cR

Example exploit

The issue lies in the decompression step. The witness generator is not constrained to produce valid boolean values (0 or 1) for r0 and r1.

This means that instead of [r0, r1] being restricted to [0, 1], they can take arbitrary values.

An attacker could choose r0 = 1,000,000 and r1 = -500,000. Let’s compute the compressed value:

cR = r0 + 2 * r1 = 1,000,000 + 2 * (-500,000) = 1,000,000 - 1,000,000 = 0

This satisfies the constraint r0 + 2 * r1 = cR, but clearly, the values [r0, r1] do not represent valid boolean values.

Root cause

During compression, the original arrays [a0, a1] and [b0, b1] were constrained to boolean values (0 or 1).

However, during decompression, the original code failed to enforce these constraints, allowing the witness generator to produce invalid values.

This lack of constraints makes the encoding non-injective, meaning the decompressed values can correspond to multiple possible outputs, enabling attackers to exploit the system.

Impact and the fix

The vulnerability arises because the decompression step lacks proper constraints, breaking the injective property of the encoding-decoding process. To fix this, the system must enforce that r0 and r1 remain within their valid range (0 or 1) during decompression.

Final remarks

o1js has a very active set of contributors and is very interested in security. They have worked hard to bring ZK technology to TypeScript developers, and overcome a set of unique challenges in bringing smart contract logic to the Mina blockchain.

We found out it remarkably straightforward to prototype proof-of-concept applications using the TypeScript library. This ease of use extends to identifying under-constrained bugs and testing circuits in ways that developers might not have anticipated.

If you’re planning to develop dapps on the Mina blockchain using TypeScript, stay tuned — our upcoming blog post will provide insights and best practices for secure development, drawing from our auditing experience.

Full audit report

Download the full o1js security audit report here.

Author:
Ben Sepanski, Chief Security Officer at Veridise

Editor: Mikko Ikola, VP of Marketing

]]>
Benjamin Sepanski[email protected]
Deploying and Debugging Remotely with Intellij2021-10-13T00:00:00+00:002021-10-13T00:00:00+00:00https://bensepanski.github.io/posts/2021/10/13/Deploying-and-Debugging-Remotely-with-IntellijIn this blog post I will show you how to sync an Intellij IDEA project with a remote server (called deploying the project to a server), and how to debug remote runs of that project.

EDIT Jan 3, 2022: Note that this process is only available for the Intellij IDEA Ultimate edition, not the Community edition.

Intellij has its own guides on these topics, check them out here:

Introduction

Intellij IDEA is an incredibly powerful IDE. If you’re anything like me, it’s become an essential component of any Java program you write. However, compiling and running large applications on my laptop gets frustratingly slow. Since I have access to bigger and better machines, I want to compile and run on those remote servers. However, I need several things before this actually improves my workflow:

  1. Keeping the local and remote in sync should be easy without using git
  2. Debugging applications running on the remote should be push-button
  3. All file-editing should be performed locally (I want the power of Intellij without having to set up X-forwarding, etc.)

(1) and (3) can be achieving using deployment: setting up a remote clone of a project that Intellij syncs in the background. (2) can be achieved using remote debug. In the rest of the blog, I’ll show you how to set this up using an example project.

Setup

I’m running Intellij 2021.2.1. Any recent version of Intellij should work. You’ll need to first set up SSH for your remote server, which I’ll call remote. For instance, you should be able to successfully run

ssh remoteUserName@remote 

I’ll assume that your SSH key is located in ~/.ssh/id_rsa. Password-based authentication is a similar process.

For this example, we’ll start by making a new Java project using the Maven build system.

Now we’re ready to set up for deployment!

Deployment

First, open the deployment configuration by going to Tools > Deployment > Configuration

Click +, and add an SFTP server. Choose whatever name you want, it doesn’t matter.

If you already have an SSH configuration setup on Intellij for your desired server, go ahead and select it. Otherwise, let’s set one up! Click the three dots next to SSH configuration to get started:

Enter the host, your remote username, and select your authentication type. I’m going to assume you’re using a password-protected private key in ~/.ssh/id_rsa. Only change the port from 22 (or set the local port) if you know what you’re doing!

Once you’re done, press “Test Connection” to make sure it works.

You can set the “root” directory if you wish. This sets what Intellij perceives as the root directory of the remote server (not the root directory of your remote project, we’ll set that later). If you do set the root, just remember that the file mappings are relative to the root you set.

Once you’re done, press OK and make sure your remote is in bold on the left menu. If it is not, select it and press the check mark to make it the default configuration.

Finally, we need to set up the file mappings. On your remote, pick some path where you want your remote to be stored. I’m going to use ~/intellijRemotes/<projectName>. Create that directory.

myRemoteUserName@remote> mkdir ~/intellijRemotes/IntellijRemoteExample

Click on “Mappings”, and copy the path to the deployed project on your remote.

Press OK, and now you’re good to go! What exactly does that mean?

  • Any file you save locally will be automatically uploaded to your remote.
  • You can upload, download, or sync any file or directory in you project by
    1. Right-clicking the file or directory
    2. Clicking “deployment”
    3. Selecting either upload, download, or sync

Look over some options by going to Tool > Deployment > Options

  • Delete target items when source ones do not exist
    • This is useful to avoid confusing errors where you’ve deleted a file locally, but Intellij does not delete the remote copy. I’d recommend setting this as long as you’re not using the remote to backup files.
  • Create empty directories
    • Have Intellij create empty directories when you upload. This is helpful if you’re outputting things to an empty directory and want it to be created on the remote when you create it locally.

You can also exclude items by names/patterns at this menu. Another place you can exclude specific paths for specific remotes is by clicking Tools > Deployment > Configuration and selecting “Excluded Paths”.

Note that you can create multiple remotes by repeating this process! Intellij only automatically uploads changes to the default. All other uploads, downloads, and syncs have to be manual.

Remote Debug

Intellij’s debugger is one of its most powerful features. There’s no reason you should lose that just because you want to run your program remotely.

First, we’re going to build a configuration that will help us connect to our remote application. Start by clicking “Add configuration.”

Click the + on the top left, and select “Remote JVM Debug”.

Name the configuration whatever you want. Enter the host name and whatever port you want to use to connect. If you have several maven projects/sub-projects, make sure to select the correct module classpath!

I usually use port 8000, but all that matters is that TCP connections can be made from your local IP address to your remote at that port. If you have issues, you can use this guide to figure out which ports are open.

Next, you’ll want to copy the “Command line arguments for remote JVM.” You’re going to need these arguments later.

Once you’re done, press “OK”.

Now, SSH into your remote, and run your application using the arguments you copied.

 myUserName@localhost> ssh myRemoteUserName@remote
 myRemoteUserName@Remote> java \
     -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000 \
     -jar myApp.jar

You should see this as output:

 Listening for transport dt_socket at address: 8000

Now go back to the local Intellij instance and run the configuration you just created! It should connect and start debugging like normal.

Note that all terminal output from your application will appear on your remote terminal, not on the local Intellij terminal. However, the Debugger tab will work as usual.

Common Pitfalls

I hope this tutorial was helpful for you! Before I let you go, I’d like to warn you of a couple pitfalls that I commonly ran into when I first started using this setup.

  1. Intellij only syncs changes made by saving/deleting files. If you switch git branches, you’ll need to manually sync with the remote.
  2. Sometimes, the Intellij remote debug application continues running after the remote application has stopped. Make sure to manually stop it locally to avoid processes clogging up your remote machine. If necessary, use htop on the remote to check for and kill these processes.
  3. If you use multiple remote deployments, only one can be the default at a time. You’ll have to manually sync other changes.
  4. If you click Tools > Deployment > Upload to ... (resp. Download, Sync) it will only Upload (resp. Download, Sync) the file which is currently open. To Upload (resp. Download, Sync) the entire project, you need to right-click on the directory from the Project tab.
]]>
Benjamin Sepanski[email protected]
Denali: A Goal-directed Superoptimizer2021-03-10T00:00:00+00:002021-03-10T00:00:00+00:00https://bensepanski.github.io/posts/2021/03/10/Denali-a-goal-directed-superoptimizerThis blog post was written for Dr. James Bornholt’s CS 395T: Systems Verification and Synthesis, Spring 2021. It summarizes the context and contributions of the paper Denali: A Goal-directed Superoptimizer, written by Dr. Rajeev Joshi, Dr. Greg Nelson, and Dr. Keith Randall.

None of the technical ideas discussed in this blog are my own, they are summaries/explanations based on the referenced works.

Denali: A Goal-directed Superoptimizer

Tdoday’s paper is Denali: A Goal-directed Superoptimizer. At the time of its publication (2002), it was one of the first superoptimizers: a code generator which seeks to find truly optimal code. This is a dramatically different approach from traditional compiler optimizations, and is usually specific to efficiency-critical straight-line kernels written at the assembly level.

Background

What is Super🦸optimization?

Plenty of compilers are optimizing compilers. However, in the strictest sense of the word, they don’t really find an optimal translation. They just find one that, according to some heuristics, ought to improve upon a naive translation. Why? Finding optimal translations is, in general, undecidable. Even for simplified, decidable versions of the problem, it is prohibitively time consuming to insert into any mortal programmer’s build-run-debug development cycle.

However, sometimes it is worth the effort to find a truly optimal solution. To disambiguate between these two “optimization” procedures, we use the term superoptimization when we are seeking a “truly optimal” solution. Superoptimization is an offline procedure and typically targets straight-line sequences of machine code inside critical loops.

With a few simplifying assumptions, the shortest straight-line code is the fastest. Consequently, we seek the shortest program.

Super🦸optimization: The Pre-Denali Era (Beginning of Time – 2002)

Alexia Massalin coined the term “superoptimization” in her 1987 paper Superoptimizer – A look at the Smallest Program. Massalin used a (pruned) exhaustive search to find the shortest implementation of various straight line computations in the 68000 instruction set. For instance, she found the shortest programs to compute the signum function, absolute value, max/min, and others. Her goal was to identify unintuitive idioms in these shortest programs so that performance engineers could use them in practice.

While Massalin’s technique was powerful, it did not scale well (the shortest programs were at most 13 instructions long in Massalin’s paper). Moreover, the output programs were not automatically verified to be equivalent to the input programs. They are instead highly likely to be equivalent, and must be verified by hand.

Granlund \& Kenner followed up on Massalin’s work in 1992 with the GNU Superoptimizer. They integrated a variation of Massalin’s superoptimizer into GCC to eliminate branching.

Until 2002, research in superoptimizers seemed to stall. Judging by citations during that period, most researchers considered Massalin’s work to fit inside the field of optimizing compilers. These researchers viewed superoptimization as a useful engineering tool, but of little theoretical interest or scalability. Rather, superoptimization was seen as an interesting application of brute-force search. Massalin and the GNU Superoptimizer seemed to become a token citation in the optimizing compiler literature.

The Denali Approach

Goal-Directed vs. Brute Force

Massalin’s superoptimizer relies on brute-force search: enumerate candidate programs until you find the desired program. Given the massive size of any modern instruction set, this does not scale well. However, since we want the shortest program, we have to rely on some kind of brute-force search. Denali’s insight is that Massalin’s search algorithm was enumerating all candidate programs, instead of only enumerating relevant candidate programs.

Denali users specify their desired program as a set of (memory location, expression to evaluate) pairs. For instance, (%rdi, 2 * %rdi) is the program which doubles the value of %rdi.

Denali’s algorithm only “enumerates” candidate programs which it can prove are equivalent to the desired program. For efficiency, it stores this enumeration in a compact graph structure called an E-Graph, then searches the E-Graph using a SAT solver.

E-Graphs

What is an E-Graph?

An E-Graph is used to represent expressions. For instance, a literal 4 or a register value %rdi is represented as a node with no children.

The expression %rdi * 4 is represented as a node ‘*’ whose first child represents %rdi and whose second child represents 4.

Bigger expressions are represented just like you would think. For instance, the expression %rdi * 4 + 1 would be represented as

So far, this just looks like an Abstract Syntax Tree. E-Graphs are distinguished from ASTs by the ability to represent multiple equivalent expressions. Suppose we wish to add the equivalence 4=2**2 to our E-graph. We do this by adding a special equivalence edge

Since there is no machine exponentiation instruction, this does not look useful at first. However, now we can add a further equivalence edge based on the fact that %rdi « 2 = %rdi * 2**2 = %rdi * * 4.

Since E-Graphs represent A=B by keeping both A and B around, they can become quite massive.

How do we build E-Graphs?

We can use proof rules to repeatedly grow the E-Graph and/or add equivalence edges. If we keep applying our proof rules until our graph stops changing, then we’ve deduced all the ways we can provably compute our expression (relative to our proof rules). For instance, in the previous example we had only three proof rules:

  1. 4 = 2**2
  2. x * 2**n = x « n
  3. If a = b, and b = c, then a = c

If we add more proof rules, we may be able to deduce faster ways to compute our expression.

Other uses of E-Graphs

An early variant of E-Graphs is described in Greg Nelson’s (one of the Denali authors) Ph.D. Thesis. These were used by Nelson in the automated theorem prover Simplify for equational reasoning. Since then, search over E-graphs via the congruence closure algorithm is used by many modern SMT solveres for reasoning about equality of uninterpreted functions (it is even taught in Dr. Isil Dillig’s CS 389L course here at UT!). For example, the Z3 SMT solver implements an E-graph, and the CVC4 Solver implements an incremental congruence closure.

Search over E-Graphs

Nodes in an E-Graph that are connected by an equivalence edge represent expressions that are equivalent according to the proof rules. Therefore, we only need to evaluate one of the nodes. Denali can use a SAT solver to figure out the optimal choice of nodes. Their encoding is not too complicated.

The basic idea of the encoding is as follows:

For each machine instruction node T,
    L(i, T) = { 1    T starts executing on cycle i 
              { 0    otherwise

Then, all we have to do is add constraints so that

  • Exactly one instruction starts executing per cycle.
  • Each instruction’s arguments are available when the instruction gets executed.
  • Some node equivalent to the root node gets computed.

Now we can find the shortest program encoded in our E-Graph by constraining the SAT solver to look for a program of length 1, then length 2, then length 3, …. until we find a solution.

Impact of the Denali Superoptimizer

Preliminary Results

The Denali paper presents several preliminary results. For the Alpha instruction set architecture, they are able to generate some programs of length up to 31 instructions. For comparison, the GNU superoptimizer is unable to generate (near) optimal instructions sequences of length greater than 5.

However, in addition to Denali’s built-in architectural axioms, the programmers specify program-specific axioms in their examples. This trades off automation for the ability to generate longer (near) optimal instruction sequences.

Super🦸optimization: The Post-Denali Era (2002 – Present Day)

Denali demonstrated that, for small programs, it is possible to generate provably equivalent, (nearly) optimal code. Since then, there has been a lot of interest in superoptimization. Here are some projects/papers that have popped up since Denali.

  • Souper is an open-source project that extracts straight-line code from the LLVM IR and applies superoptimization. It uses caching so that it can be run online (2017).
    • SMT-based goal-directed search
    • Maintained by Google, jointly developed by researchers at Google, NVIDIA, TNO, Microsoft, SES, and the University of Utah.
  • slumps is based on souper and targets web assembly (2020).
  • STOKE is a superoptimizer for x86 (2013) (which we will be reading on March 22 after spring break)
    • Uses stochastic enumerative search.
    • Is still maintained and open source at StanfordPL/stoke.
  • embecosm, a compiler research group, is developing GSO 2.0 (2015)
  • There has been research into automatic peephole optimizations (2006).

However, while there is active industry and research interest in the problem that Denali presented (finding a provably equivalent, near-optimal translation), most modern approaches (e.g. souper) rely on SMT-based synthesis techniques. Denali’s methods of superoptimization seem to have largely fallen by the wayside. Part of this is because Denali’s provably (almost) optimal program relies on a set of user-specified axioms, and is only optimal with respect to those axioms. Part of the appeal of an SMT solver is standardized theories for certain objects and operations.

Both enumerative search (e.g. STOKE) and goal-directed search (e.g. souper) are used today. In addition, Denali’s general notion of specification (a set of symbolic input-output pairs) is still used, with various project-specific modifications. Projects still rely on (often hand-written) heuristics to measure the cost/cycle-count of candidate programs.

Discussion Questions

  • As computation times are increasingly memory-bound, does superoptimization run into concerns with Amdahl’s law?
  • SMT solvers are powerful tools, but incredibly general-purpose. What types of computations are likely to be compute-bound, and can we use that domain-specific knowledge to make superoptimization faster?
  • Superoptimization seems naturally related to automated vectorization. However, people seem to treat the two problems as separate. Is there any reason automated vectorization might make superoptimization much more difficult?
  • BLIS is a framework for creating BLAS implementations for new architectures by only implementing small, finite computations kernels. Can BLIS be combined with superoptimization to automatically generate BLAS libraries for specific architectures?

References

All graphs were built using graphviz. The example E-Graph in section What is an E-Graph? is based on the example in today’s paper.

]]>
Benjamin Sepanski[email protected]