# History: off-chain root post-mortem

> **⚠️ RETRACTED: this design is exploitable. See** [**tolk-security-findings.md#c-1**](/security/tolk-security-findings.md#c-1)**.**
>
> **Do not deploy any pool that accepts an unverified depositor-supplied `newRoot`.** A malicious depositor can pay for one deposit, submit a root covering many attacker-controlled commitments, and then withdraw against each of those commitments, draining the pool. The "trust model" section below claimed this was infeasible; that claim was wrong, and the exploitable testnet pool was paused + drained on 2026-05-14.
>
> The fix is the **deposit root-update SNARK** described in ["Path to a fully trustless design"](#path-to-a-fully-trustless-design) below. The contract now requires that fix and is no longer accepting unverified roots in the main branch. This document is kept as the design rationale + post-mortem for the broken approach.

> **Status:** Originally implemented 2026-05-14, retracted same day after security review (TOLK\_SECURITY\_FINDINGS.md C-1) identified a missing binding between the on-chain `commitments` dict and the withdraw circuit. See [pool\_native.tolk](https://github.com/tonadocash/monorepo/blob/main/apps/contracts/contracts/pool_native.tolk), [depositFlow.ts](https://github.com/tonadocash/monorepo/blob/main/packages/core/src/depositFlow.ts). **Replaces:** the on-chain Poseidon merkle insert that lived in `pool_native.tolk` through 2026-05-13.

## Problem

Tornado-style privacy pools store deposit commitments in a Merkle tree and prove inclusion in a SNARK at withdraw time. The natural implementation computes the new merkle root on-chain after each deposit. On EVM with MiMC or Poseidon-over-BN254 this is cheap (a few hundred K gas). **On TVM with Poseidon-over-BLS12-381 it is not.**

| Component                                     | Cost per Poseidon hash |
| --------------------------------------------- | ---------------------- |
| \~675 field multiplications (`MULDIVMOD`)     | \~33–60K gas           |
| Constant tuple builds (9 chunks × \~1.3K gas) | \~12K gas              |
| `array.get` chunked accessors                 | \~10K gas              |
| Control flow + var assignments                | \~5K gas               |
| **Total measured**                            | **\~200K gas/hash**    |

The TVM workchain-0 block limits cap a transaction at **1,000,000 gas**. At height 20 a deposit needs 20 hashes ≈ 4M gas, an order of magnitude over the limit. Reducing height to 5 still OOG'd in practice (5 × 200K + ctx loads + dict ops exceeds 1M with no headroom). The smoke pool we ran end-to-end on 2026-05-13 used **height 2** (4 leaves/pool), good enough to prove the architecture but useless for production.

The TVM has no native scalar-field opcodes (only `BLS_G1_*`/`BLS_G2_*`/ `BLS_PAIRING` for curve points). Pure-Tolk Poseidon is bounded below by the cost of MULDIVMOD; no amount of inlining moves us within \~4× of the needed cost.

## Design

The pool no longer maintains the merkle tree. Instead:

1. **Deposit message carries the new root.** The depositor's wallet (or any helper SDK) replays the pool's `LOG_DEPOSIT` events, rebuilds the tree off-chain, appends their commitment at `nextIndex`, and submits the resulting root alongside the commitment in the `DepositNative` message.
2. **Pool stores the submitted root in the known-roots set.** No verification is performed. The pool's only on-chain merkle work is a `uDictSet` on `storage.roots`.
3. **Withdraw is unchanged.** The withdraw SNARK still binds `(root, nullifierHash, recipient, relayer, fee, refund)` and proves inclusion against the supplied root. The pool checks the root is in its known-roots set, then verifies the proof.

### On-chain message layout

```tolk
struct (0xded05701) DepositNative {
    queryId: uint64
    commitment: uint256
    newRoot: uint256  // depositor-computed; stored without verification
}
```

### Off-chain root computation

In [depositFlow.ts](https://github.com/tonadocash/monorepo/blob/main/packages/core/src/depositFlow.ts):

```ts
export async function computeNewRoot(
  client: TonClient,
  poolAddress: string,
  newCommitment: bigint,
): Promise<bigint> {
  const existing = await loadAllCommitments(client, poolAddress);
  const tree = await buildMerkleTree({ leaves: existing });
  return tree.insertLeaf(newCommitment);
}
```

The depositor walks `LOG_DEPOSIT` events newest→oldest, reverses to insertion order, rebuilds the tree using the off-chain Poseidon (same one verified for parity against the circom reference in [`check-poseidon-parity.ts`](https://github.com/tonadocash/monorepo/blob/main/apps/contracts/scripts/check-poseidon-parity.ts)), and computes the new root.

## Trust model: RETRACTED

**Everything below this header was wrong.** Preserved for the post-mortem only. The actual attack:

1. Attacker generates 100 note pre-images locally: `(n_i, s_i)` for i=1..100.
2. Attacker computes `c_i = Poseidon(n_i, s_i)` and builds an off-chain merkle tree with leaves `[c_1, ..., c_100]`. Root = `R`.
3. Attacker pays for **one** deposit with `commitment = c_1, newRoot = R`. Pool stores `c_1` in commitments and `R` in roots.
4. For each `i ∈ 2..100`, attacker generates a Groth16 proof: "I know `(n_i, s_i)` with `c_i = Poseidon(n_i, s_i)` and `c_i` is in the tree with root `R`." All checks pass: `R ∈ storage.roots`, `nullifierHash_i` unspent, SNARK valid. Pool pays out.
5. Attacker spends `1 × denomination`, withdraws `100 × denomination`.

The error in the original analysis: the withdraw SNARK proves "I know a pre-image whose commitment is in some tree with root `R`"; it does **not** bind to the on-chain `commitments` dict. The attacker knows the pre-images of `c_2..c_100` because they generated them. There's no "infeasibility" to invoke. The Poseidon+Groth16 security only stops forging proofs against secrets you don't know; it doesn't stop you from manufacturing your own secrets and shoving them into a tree the pool accepts as authoritative.

The fix is to **prove on-chain that the submitted root is the result of inserting&#x20;*****this specific paid commitment*****&#x20;into the previous canonical root**, i.e., the root-update SNARK described in the next section, which the contract now requires.

***

### (original, wrong) What a malicious depositor *can* do

* **Submit a wrong root.** They can compute a root that excludes other commitments, or just put garbage in the `newRoot` field. The pool accepts it into the known-roots set.

### (original, wrong) What a malicious depositor *cannot* do

* **Steal funds.** ~~The withdraw SNARK proves both `nullifierHash = Poseidon(secret_n)` AND `commitment = Poseidon(secret_n, secret_s)` AND `commitment ∈ tree-with-root-R`. To steal, an attacker would need to produce a SNARK proving membership against a fake root for a commitment whose pre-image (nullifier, secret) they don't know, which is exactly what Groth16 + the BLS12-381 ceremony makes infeasible.~~ **Wrong.** See the attack above; the attacker *does* know the pre-image because they generated it.
* **Damage other users' withdraws.** ~~Other users withdraw against roots whose pre-image *they* computed locally.~~ Still true at the pre-image level (attacker can't forge nullifiers for someone else's deposit), but the attacker can extract value from the pool without ever knowing anyone else's secret, which is the actual concern.
* **Replay another user's nullifier.** Still true: `nullifierHash` is in the `nullifiers` dict on first use.

## Limitations

1. **Withdrawer must scan history.** To withdraw, the user (or their relayer) reads every `LOG_DEPOSIT` event since the pool was deployed. At 2^20 deposits and \~1 KB per event, the worst-case scan is \~1 GB of tx data. In practice the relayer maintains a synced index.
2. **No on-chain root-consistency proof.** The pool's `roots` dict can accumulate fake entries indefinitely. They take storage; at TON's \~0.01 TON/MB/year storage rent they're not a DoS surface, but they are clutter. A pruning admin op or a TTL is worth considering.
3. **Recovery requires LOG\_DEPOSIT availability.** If a TON archive node loses old transactions (testnet aggressively prunes after a few months), commitments deposited before the prune horizon can't be withdrawn unless the user kept their own local index. Mainnet archive providers (toncenter, getblock) generally retain forever.

## Path to a fully trustless design

A future iteration can eliminate the "garbage root" surface entirely with a **root-correctness SNARK** submitted alongside the deposit:

```tolk
struct DepositNative {
    commitment: uint256
    newRoot: uint256
    rootProof: ...  // Groth16 proof that newRoot = insert(prevRoot, commitment)
}
```

The new circuit (`root_update.circom`):

```circom
template RootUpdate(levels) {
    signal input prevRoot;
    signal input newRoot;
    signal input commitment;
    signal input leafIndex;
    signal input pathElements[levels];
    signal input pathIndices[levels];

    // Verify prevRoot is consistent with pathElements at leafIndex.
    // Verify newRoot = insert(commitment, leafIndex, pathElements).
}
```

Estimated effort: **1–2 days of engineering plus a multi-party ceremony** for the new zkey. The verifier on-chain is the same Groth16 verifier we already have, just with different constants. The deposit verifies one more pairing (\~150K gas) instead of running Poseidon (would be impossible in pure Tolk).

This is the recommended path before mainnet. It is documented in [security-review.md §I-4 Path Forward](/security/security-review.md) as the follow-up to the gas-cap issue.

## Test coverage

The off-chain-root architecture is covered by:

* **Off-chain unit tests** ([`packages/core/src/*.test.ts`](https://github.com/tonadocash/monorepo/blob/main/packages/core/src/README.md)): 60 tests covering Poseidon parity, merkle tree correctness, note parsing, address ↔ field encoding, deposit derivation.
* **Sandbox tests** ([`apps/contracts/tests/`](https://github.com/tonadocash/monorepo/blob/main/apps/contracts/tests/README.md)): 35 tests covering the contract end-to-end via `@ton/sandbox`:
  * `pool.deposit.test.ts` (18): happy path, value validation, field-range checks, duplicate-commitment rejection, paused state, malformed message, empty-body / top-up.
  * `pool.admin.test.ts` (7): pause/unpause, recover-stuck, not-owner rejection.
  * `pool.withdraw.test.ts` (10): happy path, replay protection, unknown root, field-range, fee bounds, paused state, recipient/ relayer binding.
* **Live testnet e2e** ([`shell/testnet.sh`](https://github.com/tonadocash/monorepo/blob/main/shell/testnet.sh)): Deploy → deposit → wait for landing → withdraw → verify recipient delta. Run: `pnpm e2e:testnet`. Last green run: 2026-05-14, pool [`EQAtWP0IruKY_Ra8AE6VbGf41IxBMKj4YlaSl-7HZAPJ4lq-`](https://testnet.tonscan.org/address/EQAtWP0IruKY_Ra8AE6VbGf41IxBMKj4YlaSl-7HZAPJ4lq-).


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.tonadocash.com/protocol-reference/off-chain-root.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
