Skip to content

SDKs and the API gateway

Four pieces: a gateway in front of the node, uniffi bindings for Python/Kotlin/ Swift, a wasm build for browsers, and a TypeScript client.


What is verified, and what is only generated

Section titled “What is verified, and what is only generated”

The honest matrix first, because it is the part most easily overstated.

ComponentBuiltTestedWhere
api-gateway (REST + GraphQL)yes13 testscrates/api-gateway/tests/gateway_tests.rs
sdk-ffi Rust coreyes9 testssdks/sdk-ffi/src/lib.rs
Python bindingsyes9 tests, real cdylibsdks/sdk-ffi/bindings/python/test_bindings.py
Kotlin bindingsgenerated onlynono kotlinc on the build host
Swift bindingsgenerated onlynono Swift toolchain on the build host
sdk-wasm coreyes2 + 8 paritysdks/sdk-wasm/src/lib.rs, crates/node/tests/hybrid_parity_tests.rs
maya2c.jsyes19 E2E testssdks/sdk-js/test/e2e.test.ts

uniffi emits Kotlin and Swift as text without needing those toolchains. Emitting is not the same as working, and a generated-but-never-compiled binding is not a working SDK. Both are shipped labelled, not claimed.

To close the gap: a CI runner with kotlinc and a macOS runner with Swift, running the equivalent of the Python suite.


The node’s JSON-RPC port has no authentication — sound for something bound to a pod network, unsound to expose. The gateway is what makes a public interface possible.

MethodProxied?
get_balance, get_block_by_height, get_supplyyes
send_raw_transactionyes — a public chain is for this
get_mining_candidateno
submit_blockno

The miner methods are named in allowlist::DENIED_METHODS rather than merely absent, so the exclusion has a test behind it. get_mining_candidate hands out the header a miner is searching; submit_block makes every node on the network do full block validation on demand. Both stay on the node’s own port, to the operator already inside the trust boundary.

The check lives in NodeClient::call — the single point every outbound call passes — not at each route. A route that forgot the check would be a hole, and the compiler cannot see a missing call to a free function.

schema() installs a depth limit of 6 and a complexity limit of 200, and there is no constructor without them. A schema_unlimited for tests would be the version somebody eventually wired into main.

GraphQL lets a client compose its own query; over RocksDB that is an unbounded work surface that ships enabled. No mutations are exposed — writes go through REST, where the size ceilings and sealed validation live, and a mutation would be a second write path that drifts from them.

The gateway validates shape — hex, length, non-empty — and forwards. It never decrypts and holds no committee share. A gateway that could read the threshold-encrypted mempool would be the observer that mempool exists to remove.

MAX_SEALED_PAYLOAD_BYTES (64 KiB) is checked against the string before hex::decode allocates, so a hostile length never becomes a hostile allocation.

GatewayError::Upstream carries the node’s message to the log and renders as a flat "upstream node error" to the caller. Echoing it would publish the node’s internal address, paths, and versions to anyone who could provoke a failure.


A secret key never crosses the FFI boundary

Section titled “A secret key never crosses the FFI boundary”

HybridSigningKey is Zeroizing — wiped on drop. That guarantee ends at the boundary. In Python the secret becomes a bytes object: immutable, freely copied by the interpreter, resident until GC, impossible to wipe. Kotlin and Swift are no better.

So SigningKey is an opaque handle. Its entire public surface is from_seed, generate, public_key, address, sign. There is deliberately no secret_bytes(), and test_there_is_no_way_to_export_the_secret pins that surface exactly — a keyword filter would flag from_seed, which is a constructor (secret entering, which is fine), and miss an exporter named export or raw.

HybridSigningKey lives in custom-l1-node, which links RocksDB, so the release cdylib is 5.0 MB with a database embedded in it. That is a real cost for a mobile SDK, and the structural fix is to extract hybrid into its own crate. That refactor touches a consensus path and is deliberately not bundled into an SDK change.


3. The browser SDK, and the duplication in it

Section titled “3. The browser SDK, and the duplication in it”

sdk-wasm cannot depend on custom-l1-node: RocksDB is C++, and C++ does not target wasm32-unknown-unknown. There is no feature flag that removes it, because the node is the database.

So the hybrid construction is composed there from the same two primitives the node composes it from — fips204 and maya-crypto-pq, both pure Rust. That is duplication of a consensus-critical encoding.

Duplication is dangerous when undetected, so it is detected. crates/node/tests/hybrid_parity_tests.rs signs with the node and verifies with the SDK, derives addresses both ways, and checks that a forged half is refused in each direction. Eight tests.

It has already caught one error. The SDK’s ADDRESS_DOMAIN was written as maya-address-v3; it is actually custom-l1-node.address.v3. That mistake would have produced plausible-looking addresses that no key could ever spend — a user would fund one and lose the funds.

verify_ml_dsa is not enough to accept a transaction

Section titled “verify_ml_dsa is not enough to accept a transaction”

It checks 3,309 bytes instead of 11,165, so a UI can show a provisional result while the full check runs. Anything that decides to spend must use verify_hybrid. Checking only the lattice half would accept exactly the forgery the hybrid scheme exists to prevent.


maya2c.js talks to the gateway, not a node. Pointing it at a node would work for reads and would be the wrong thing to ship.

No signature scheme is reimplemented in TypeScript. There is already one hand-maintained copy of the hybrid encoding — see the parity test — and one is the most this project should have.

19 tests, each starting a mock gateway on an ephemeral port and speaking real HTTP. Nothing is stubbed: a stubbed fetch tests that the client calls the function it was given, and skips URL construction, JSON handling, status mapping, and the abort path — which is most of what a client is.

Two behaviours worth naming:

  • 4xx vs 5xx. GatewayError.retryable is status >= 500. A client that collapsed both into a string would retry malformed requests forever.
  • GraphQL 200-with-errors. GraphQL returns HTTP 200 and an errors array. A client checking only the status treats a rejected query — including one refused by the complexity limit — as a success with data: null. That is the single most common GraphQL client bug, and it is tested.

  • New StateDB read paths. The node exposes six RPC methods; dex, oracle, governance, sealed-mempool and shielded state have no RPC surface at all. The GraphQL schema serves what the node serves, and nothing more. Adding those read paths is the bulk of a richer query API.

  • No “DAG state” query, deliberately. crypto::dag is the memory-hard proof-of-work dataset, not a block graph, and it is not chain state. The block-graph work in maya-blockgraph is a research branch no consensus path reaches. There is nothing to query, and a field returning something plausible would be worse than its absence.

  • No measured wasm signing figures. wasm-pack is not installed on this host, so the packaged .wasm artifact was not produced and browser signing time and bundle size are unmeasured. Expect materially worse than native — there is no AVX2 and no SHA extensions in wasm — and measure before promising a UX.

    The crate itself does now compile for wasm32-unknown-unknown, as of 2026-09-20; until then it never had. getrandom refuses that target unless it is told where entropy comes from, and it reaches this crate only transitively — through maya-crypto-pq at 0.4 and through fips204’s rand_core 0.6 at 0.2, so both majors need their browser backend turned on, and a transitive dependency’s feature cannot be enabled from outside. Both are named in sdks/sdk-wasm/Cargo.toml under a cfg(target_arch = "wasm32") table for that reason and no other, with the matching --cfg getrandom_backend="wasm_js" in .cargo/config.toml. .github/workflows/nightly.yml builds it so it cannot rot back.