# Solana integration adversarial fund safety audit

**Remediation update (1 October 2026):** The source findings below have been addressed in local frontend and relayer worktrees. See the [remediation record](/audits/groth16/solana-remediation/) for changes, validation and remaining rollout requirements. This audit describes the pre-fix revision.

Audit completed 1 October 2026 UTC (30 September local time). This source audit found four implementation defects and one recovery design risk. The highest impact is permanent loss of access to newly created notes if delivery fails and the sole browser copy is subsequently lost. Other findings can block withdrawals or waste relayer funds. No unauthorized vault withdrawal, forged proof acceptance, or double spend was demonstrated.

**Release assessment:** address the note durability gap and recovery deadlocks before relying on this integration for real funds. Passing the existing tests does not resolve these findings.

## Scope and evidence

The new Solana implementation is in the existing `.codex-worktrees/solana-audit-fixes-*` checkouts. The top-level checkouts do not contain the same integration. Findings apply to these exact source revisions:

| Component | Commit |
| --- | --- |
| privacy-protocol | `4c234495b256adc611dc332a86b284163d26aaa0` |
| stealthos-relayer | `2d24a354b6a71e5743d4e0f1842978758572b947` |
| privacy-frontend | `d7dadc7512d087b198b40a5bb3bd1511180b0404` |

Reviewed the pool's deposits, settlements, buffers, rollover and admin instructions; proof/public-input binding and relevant circuit constraints; SDK encoding; relayer validation, signing, fees and durable spend accounting; and frontend submission, confirmation, note storage and journal recovery. Production sources were not changed. Reproduction tests and logs are saved beside this report.

Severity distinguishes irreversible loss from recoverable loss of access. High findings warrant correction before a real-funds launch; this does not mean all three high findings permit theft.

| ID | Severity | Finding | Fund impact |
| --- | --- | --- | --- |
| F5 | High | Settlement precedes durable note delivery, and delivery failure still permits success | Conditional permanent loss of recipient/change/deposit note access |
| F1 | High | HTTP serialization drops the definitive refusal flag | Selected funds can remain blocked in the app after a transaction was never submitted |
| F2 | High | Recovery treats the newest transaction mentioning a PDA as its creation transaction | An unrelated transaction can indefinitely block recovery and recipient delivery |
| F3 | Medium | Empty-delivery withdrawals never complete their journal records | 64 records exhaust the journal and block subsequent actions |
| F4 | Medium | Relayer treats a processed signature as expired and signs a replacement | Duplicate paid transactions; staging corruption/failure and excess relayer cost |

## F5 Settlement can succeed before the only recoverable note is durably delivered

**Locations:** delivery and error handling (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/solana-app/hooks/useSolanaNotes.ts:416`), success status (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/solana-app/hooks/useSolanaActions.ts:373`), browser journal persistence (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/journal.ts:212`), random note generation (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/notes.ts:115`).

The flow writes output notes into an encrypted browser journal, settles on chain, and then attempts inbox delivery. `completeDeliveries` catches a delivery failure, logs a warning, and resolves normally. The caller can display “Sent … privately.” Recipient notes use `keepLocal: false`, so the sender's ordinary note cache does not provide another copy. The sender's journal remains the recovery mechanism until remote delivery succeeds.

The note's random secret is freshly generated, not deterministically reconstructed from a wallet restore. The Solana data router (`.codex-worktrees/solana-audit-fixes-stealthos-relayer/src/backend/engine-api/solana-data.js:100`) also excludes the existing EVM recovery endpoints; the audited Solana submission path has no off-device, pre-submission journal backup.

**Failure sequence:** settle a private transfer; fail the note-store request; lose or clear the sender's browser storage before any successful retry. The recipient has no delivered secret, the sender has no journal, and the commitment cannot reconstruct that secret. Access to that output is then lost. Deposits and self/change outputs have the same browser failure domain while their remote delivery is pending.

**Evidence:** the F5 React-hook reproduction invokes the real delivery code with a finalized record and a failing store. It confirms normal return, an undelivered note retained only in the journal, no recipient note in the sender's note list, and no recoverable journal after storage loss. The test models a finalized settlement; it does not move money. Permanent fund inaccessibility is the consequence of losing the secret required to spend the committed note.

**Limits:** a delivery failure alone does not destroy funds. Reloading with local storage intact is recoverable. This finding requires loss of the remaining local secret before a successful retry.

**Fix:** durably back up encrypted output secrets off-device before submitting a transaction that commits their value. Use scoped sender recovery records and/or pending recipient envelopes with authenticated receipt and a safe finalization protocol. Return structured delivery status and show “settled, delivery pending” until durability is acknowledged. Do not treat the settlement itself as retryable merely because delivery failed.

## F1 A definitive pre-signing refusal becomes a permanently pending client intent

**Locations:** HTTP error response (`.codex-worktrees/solana-audit-fixes-stealthos-relayer/src/relayer/solana-pool-v2/route.js:111`), service's definitive refusal flag (`.codex-worktrees/solana-audit-fixes-stealthos-relayer/src/relayer/solana-pool-v2/service.js:404`), client parsing (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/relay.ts:146`), reconciliation (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/reconcile.ts:302`).

The relayer service sets `details.submissionNotAttempted = true` when its durable record establishes that signing never occurred. The HTTP route omits `details`. Consequently, the browser cannot distinguish this explicit refusal from an ambiguous lost response and keeps the record active.

A changed fee requirement or an invalidated root can make the original intent unusable. Refresh replays that obsolete intent. A newly proved intent for the same nullifier set gets the existing journal record, and settlement rejects the resumed record (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/settle.ts:359`). For a wallet dependent on that note selection, the app has no successful withdrawal path without journal repair or a change that makes the original intent acceptable again.

**Evidence:** the real HTTP route serializes a service error carrying the flag. A frontend test consumes that exact JSON through the real relay client, runs reconciliation, and proves that a replacement fee intent resumes the old fee. A control with the flag preserved correctly marks the refusal as confirmed failure.

**Impact:** funds remain on chain and cryptographically spendable; this is an application access lock, not theft or an irreversible on-chain freeze.

**Fix:** serialize only the validated, safe refusal boolean, and preserve the service's durable-history guard. Do not infer “not submitted” from HTTP status or expose arbitrary error details. Add an HTTP-to-client recovery test that permits a fresh proof after a genuinely unsigned refusal and forbids that release after any signing attempt.

## F2 Any later transaction mentioning the nullifier can block lost-response recovery

**Locations:** newest-signature lookup (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/reconcile.ts:46`), settlement recovery branch (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/reconcile.ts:244`).

When the settlement response was lost and no signature was saved locally, recovery calls `getSignaturesForAddress` with `limit: 1` and assumes that the newest successful transaction is the one that created the nullifier. The API actually returns transactions mentioning the address in their account keys, newest first. [Solana RPC documentation](https://solana.com/docs/rpc/http/getsignaturesforaddress).

A third party can include the public nullifier PDA in an unrelated transaction, such as a small lamport transfer. Recovery then selects that later transaction. Settlement-event validation properly rejects it, but the outer recovery handler leaves the record pending; every refresh selects the same unrelated transaction. Because the nullifier exists, this branch also bypasses the relayer replay path that could return the real settlement signature.

**Evidence:** the F2 test supplies a real encoded settlement event and matching commitment metadata followed by an unrelated successful transaction mentioning the nullifier. Two reconciliation attempts produce no recovered outputs or delivery work. Removing only the unrelated transaction lets the same record recover successfully.

**Preconditions and impact:** the legitimate settlement succeeded, its response/signature was lost, and a later transaction mentions its nullifier PDA. Inputs are spent, but the app cannot finalize or deliver outputs through this path. The sender journal still contains secrets, so correct-signature recovery can repair access. Undelivered recipient funds can remain inaccessible indefinitely; loss of the journal can combine with F5.

**Fix:** paginate transaction history and identify the actual pool settlement by authenticated program events, expected nullifiers, output commitments and relevant slot metadata. Treat a durable relayer signature as another candidate to validate. Never equate an arbitrary successful address mention with account creation. The deposit branch uses the same helper and should receive the same correction.

## F3 Full withdrawals with no change permanently consume active journal slots

**Locations:** delivery loop (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/solana-app/hooks/useSolanaNotes.ts:346`), withdrawal outputs (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/operations.ts:324`), 64-record limit (`.codex-worktrees/solana-audit-fixes-privacy-frontend/src/joinsplit-v2/solana/journal.ts:388`).

A full withdrawal creates no output notes and therefore no deliveries. The only completion transition in `completeDeliveries` occurs inside its delivery loop via `markSolanaJournalDeliveryCompleted`. Empty arrays skip that transition, leaving successfully finalized withdrawals in an active state.

After 64 such withdrawals in the same scope, `prepareSolanaJournalRecord` rejects new operations. Refresh does not clear these already-successful records, so deposits, transfers and withdrawals requiring new records become unavailable.

**Evidence:** the F3 test creates 64 finalized, zero-output withdrawals and calls the real React hook's delivery completion for each. All 64 remain active, and preparing the next deposit fails with the capacity error.

**Impact:** completed withdrawals are not reversed or lost. Subsequent use of funds is blocked until records are repaired.

**Fix:** explicitly complete a chain-validated finalized record with no required deliveries, and migrate existing finalized empty records. Do not blindly complete prepared or broadcast records. Keep records with actual undelivered notes recoverable.

## F4 Processed transactions can be incorrectly retried with new signatures

**Locations:** confirmation and expiry (`.codex-worktrees/solana-audit-fixes-stealthos-relayer/src/relayer/solana-pool-v2/executor.js:69`), fresh-signature retry (`.codex-worktrees/solana-audit-fixes-stealthos-relayer/src/relayer/solana-pool-v2/executor.js:99`), buffer append (`.codex-worktrees/solana-audit-fixes-privacy-protocol/programs/solana-joinsplit-pool/src/instructions/buffer.rs:75`), fee estimate (`.codex-worktrees/solana-audit-fixes-stealthos-relayer/src/relayer/solana-pool-v2/fees.js:71`).

The relayer checks block height at confirmed commitment. If it exceeds `lastValidBlockHeight`, a second status lookup that still reports `processed` is treated as `landed: false`. `sendStep` then signs the same instructions with a new blockhash, even though the first signature was observed and may still finalize. This is not a safe proof of non-execution. See Solana's [confirmation guidance](https://solana.com/developers/cookbook/transactions/confirmation).

Staging writes append bytes, so executing a replacement is not generally idempotent: it can duplicate a chunk or fail an overflow/payload check. Additional successful or failed transactions also cost fees, while the estimate counts one transaction per planned step.

**Evidence:** one controlled RPC test returns processed status both before and after the height check, then finalized for the original signature. The executor prematurely returns `landed: false`. A second test exercises `sendStep` and records two distinct signed transactions for the same instruction while the first remains processed.

**Impact and limits:** the duplicate-signing behavior is directly reproduced. Staging damage and excess fees follow from non-idempotent appends and paid retries; no real network fee was spent in the test. On-chain nullifiers protect against successful double settlement, so this is not evidence of a double withdrawal. Retry counts and spend controls constrain exposure.

**Fix:** use finalized height for definitive expiry, and do not classify a still-observed processed signature as absent. Reconcile its final outcome before rebuilding. Persist the locally known signature before network submission; verify step effects before repeating non-idempotent instructions. Budget all possible paid attempts. The frontend confirmation logic is a useful reference for stricter expiry handling.

## Validation performed

| Suite | Result | Evidence |
| --- | --- | --- |
| Existing frontend tests | 1,094 passed across 148 suites | frontend-baseline.log (`audit-reports/solana-adversarial-20261001/frontend-baseline.log`) |
| Existing Solana relayer/backend tests | 35 passed, 1 skipped | relayer-baseline.log (`audit-reports/solana-adversarial-20261001/relayer-baseline.log`) |
| SDK Solana encoding, pool and verification | 32 passed | sdk-tests.log (`audit-reports/solana-adversarial-20261001/sdk-tests.log`) |
| Native Solana program tests | 30 passed | pool-wsl.log (`audit-reports/solana-adversarial-20261001/pool-wsl.log`) |
| New frontend reproductions | 4 passed | frontend-reproductions.log (`audit-reports/solana-adversarial-20261001/frontend-reproductions.log`) |
| New relayer reproductions | 3 passed | relayer-reproductions.log (`audit-reports/solana-adversarial-20261001/relayer-reproductions.log`) |

The new tests deliberately assert the defective behavior; their passing results confirm reproduction, not remediation. The hook log contains React act warnings from asynchronous initialization. Assertions still completed successfully.

The skipped test requires a real PostgreSQL configuration and covers HTTP delivery persistence and namespace isolation. The first native Windows Rust build could not link because MSVC `link.exe` was unavailable; the native tests subsequently passed in WSL. Those tests use the `unsafe-test-setup-keys` feature and native program testing, not a production SBF deployment.

### Reproduce the findings

Run run-reproductions.ps1 (`audit-reports/solana-adversarial-20261001/run-reproductions.ps1`) in PowerShell. It uses the existing worktrees and dependencies, runs the relayer reproduction to generate the HTTP response fixture, temporarily installs the two frontend audit tests, and removes only those unchanged copies afterward.

Sources: relayer reproductions (`audit-reports/solana-adversarial-20261001/relayer-reproductions.test.mjs`), frontend recovery reproductions (`audit-reports/solana-adversarial-20261001/audit-20261001-recovery.test.ts`), frontend delivery reproductions (`audit-reports/solana-adversarial-20261001/audit-20261001-delivery.test.tsx`).

Existing-suite commands, from the corresponding worktrees:

```text
Relayer:
node --test src/test/solana-pool-v2-config.test.js src/test/solana-pool-v2-settlement.test.js src/test/solana-pool-v2-route.test.js src/test/solana-devnet-startup.test.js src/test/engine-solana-data.test.js

Frontend (PowerShell):
$env:CI='true'
npm test -- --watch=false --runInBand --testMatch '**/src/**/*.{spec,test}.{js,jsx,ts,tsx}' --testPathPattern=solana

Protocol SDK:
node --test packages/sdk/test/solana-encoding.test.mjs packages/sdk/test/solana-pool.test.mjs packages/sdk/test/solana-verify.test.mjs

Protocol native tests (WSL, from protocol worktree):
cargo test --locked --manifest-path programs/Cargo.toml -p stealthos-solana-joinsplit-pool --features unsafe-test-setup-keys
```

The frontend checkout path itself includes “solana,” so that test-path filter selected the entire frontend suite. The WSL run used a separate existing build cache.

## Boundaries of the conclusion

The reviewed pool includes proof binding for payout/action fields, nullifier and commitment reuse protection, exact SPL deposit balance checks, restrictive Token-2022 extension handling, and authority checks. No bypass was demonstrated in this audit. These observations do not constitute a proof of soundness or a blanket security approval.

This was a source review with local tests and controlled RPC/storage faults. No production transactions were submitted. Deployed bytecode, upgrade authority custody, production proving-key ceremony provenance, live balances, operator configuration and a real PostgreSQL deployment were not verified. The configured frontend surface is Solana devnet. Findings therefore describe the audited integration's behavior and its implications for any future real-funds use, not an assertion that a live mainnet vault is currently exposed.
