Private Rust backend for creating and verifying Merkle trees used by Sablier airdrops. Deployed as Vercel Lambdas. GPL-3.0 source published for transparency; not intended for third-party self-hosting.
@README.md
- Rust 2021 edition, nightly toolchain (CI pins
dtolnay/rust-toolchain@nightly) vercel_runtime2 — each endpoint is its own Lambda binarymerkle-tree-rs(OpenZeppelin-compatibleStandardMerkleTree) for EVMsolana-sdk+bs58+ customutils::solana_merklefor Solanaethers-rsfor EIP-55 address handling,sha3for Keccakreqwest+multipartfor Pinata IPFS uploads/downloadscsv+regexfor campaign parsing, validationtokioasync runtime,mockitofor HTTP mocking in tests
api/*.rs— thin Vercel Lambdamainshims; one binary per endpoint (see[[bin]]table inCargo.toml).src/—sablier_merkle_apilibrary crate.controller/— request handlers. Each exposeshandler(generic, testable) andhandler_to_vercel(Vercel adapter).services/ipfs.rs— Pinata upload + IPFS gateway download. All errors funnel throughIpfsError.utils/—auth(bearer check),csv_validator,request(query parsing),solana_merkle.csv_campaign_parser.rs—CampaignCsvParsed::build_ethereum/build_solana.data_objects/—dto,query_param,response.
| Binary | Auth | Purpose |
|---|---|---|
create |
Bearer | Build EVM Merkle tree from CSV, pin to IPFS |
create_solana |
Bearer | Same, Solana addresses |
validity |
Bearer | Verify an existing tree by CID |
eligibility |
Bearer | Fetch proof for (cid, address) — EVM |
eligibility_solana |
Bearer | Same, Solana |
health |
Public | Liveness probe |
Eligibility responses set Cache-Control: public, s-maxage=31536000, immutable — CIDs are content-addressed, so Vercel's edge cache replaces the old Redis layer. Do not weaken this without replacing the caching story.
cargo fmt --all -- --check— formatting gate (CI enforces)cargo clippy --all-targets -- -D warnings— lints (CI enforces, warnings denied)cargo test— unit tests; some tests hitSERVERmutex inutils::async_testand must share env setupcargo build --release— local build; real deploy cross-compiles viacargo zigbuild --target x86_64-unknown-linux-gnu
Deploy is manual via Deploy on Vercel workflow (workflow_dispatch). Do not add automatic deploys on push.
- Provider: Vercel.
- Vercel scope:
sablier; project:merkle-api; project ID:prj_a6lRAog7uUzq5mXCMsuVYGw3hIEM; production hostname:sablier-merkle-api.vercel.app. - Use read-only Vercel checks for production incidents, e.g.
vercel logs --environment production --scope sablier --project merkle-api --no-branchandvercel metrics <metric> --scope sablier --project prj_a6lRAog7uUzq5mXCMsuVYGw3hIEM. - Sentry org:
sablier-labs. There is no dedicatedmerkle-apiSentry project as of 2026-06-18; the only Merkle-labeled project ismerkle-tracker. Usesentry-cli issues list -o sablier-labs -p merkle-tracker ...only as adjacent telemetry, and state that it is not this Rust Lambda service unless instrumentation changes. - This service has no app-owned Postgres or Redis. Production backing storage is Pinata/IPFS gateway via Vercel env vars; summarize env var names only, never values.
- Normal EVM eligibility path:
app.sablier.combrowser calls the portal endpoint/api/merkle/eligibility; portal calls Railwaymerkle.tracker.readathttps://sablier-merkle-api-evm.up.railway.app; Railwayapi-evmchecks Redis first, then falls back to this Rust Vercel endpoint athttps://sablier-merkle-api.vercel.app/api/eligibilityon cache miss or cache error. - Status normalization is cross-service behavior:
- Rust
200returns the proof payload. - Rust
400currently means "not eligible". - Railway treats Rust sub-500 non-OK responses as eligibility verdicts.
- Portal maps Railway
400or404to browser200 { eligible: false }. - Rust
5xxresponses are retried by Railway once, then surfaced as502/504provider failures.
- Rust
- Do not change Rust ineligible status/body semantics casually. Do not map malformed input, invalid CIDs, or provider failures to a sub-500 "eligibility verdict" unless portal and Railway behavior are intentionally updated. Avoid changes that make
app.sablier.comshow "not eligible" for upstream/provider failures. - Existing load protection: portal has a
(cid,address,id)eligibility cache (5 min for eligible, 60 min for ineligible), portal WAF rate-limits/api/merkle/eligibilityat 10 requests / 600s by IP+JA4, and Railway coalesces identical in-flight(cid,address)fallback calls.
rustfmt.toml:max_width = 120,imports_granularity = "Crate",use_small_heuristics = "Max",tab_spaces = 4,wrap_comments = true. Alwayscargo fmtbefore proposing changes.- Prefer
let ... elseearly returns over nestedmatch/if let— existing controllers use this pattern consistently. - Keep the
handler/handler_to_vercelsplit: business logic lives in the purehandlerso tests can call it directly without aVercel::Request. - Error responses go through
data_objects::response::{message, bad_request, ok, to_vercel, to_vercel_message}— do not hand-roll JSON responses. - Do not introduce
unwrap()/expect()on external input paths. Internal invariants (e.g.,serde_json::to_stringon a tree we just built) are acceptable. - Doc comments (
///) on public items. Inline//comments only for non-obvious invariants — do not narrate what the code does.
- Protected endpoints call
utils::auth::is_authorized. It is fail-closed: missing or emptyMERKLE_API_BEARER_TOKENrejects every request. Preserve this property — never fall back to "allow when unconfigured". - Expected header is exact match:
Authorization: Bearer <MERKLE_API_BEARER_TOKEN>. No scheme variations. - Never log bearer tokens, Pinata keys, or full request headers.
- Required env vars (see
.env.example):PINATA_ACCESS_TOKEN,PINATA_API_KEY,PINATA_SECRET_API_KEY,PINATA_API_SERVER,IPFS_GATEWAY,MERKLE_API_BEARER_TOKEN. Deploy-only:VERCEL_ORG_ID,VERCEL_PROJECT_ID,VERCEL_TOKEN.
create/create_solanareturn HTTP 200 for malformed input (missingdecimals, bad content-type, unreadable body). This is intentional legacy behavior to preserve client compatibility — there is aReview candidatecomment marking it. Do not "fix" to 4xx without coordinating with the frontend team.handler(pure) returns proper status codes (400/500). The 200-on-bad-input quirk only exists in the Vercel adapter.- Mockito tests share a single
SERVERmutex (src/utils.rs) on port 8000. New HTTP-facing tests mustlock().awaitthat server andsetup_env_varsto avoid races. StandardMerkleTree::ofleaves are[index, address, amount]typed as[uint, address, uint256]— matches the on-chainMerkleLockup/MerkleLLverifier layout. Don't reorder.
- Put unit tests in
#[cfg(test)] mod testsinside the controller/service they exercise — matches existing layout. - For IPFS paths, mock Pinata via
mockitousing the sharedSERVERfromutils::async_test. - Doc-tests in
csv_campaign_parser.rsare real tests — keep them passing.
- Keep changes scoped. CSV parsing, Merkle construction, and IPFS are load-bearing — touch them only with a reason in the PR description.
- Run
cargo fmt --all -- --checkandcargo clippy --all-targets -- -D warningslocally; CI will fail otherwise. - If you add an endpoint: new
api/<name>.rsLambda shim, new[[bin]]entry inCargo.toml, newsrc/controller/<name>.rs, and register it insrc/controller.rs.