Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API reference

This page is the contract implemented in this repository. Request and response fields are the serde names on the Rust structs. Nothing here adds a route, a field, or a header that the code does not set.

The crate depends on axum 0.8.9. Where a failure comes from axum’s Json extractor rather than this crate’s AppError, the status below is the one axum’s own tests assert for that version.

HTTP

build_router registers four routes and no middleware:

MethodPathHandler
GET/healthservice::health
POST/payment-sessionservice::payment_session
GET/payments/{payment_id}service::payment_status
GET/pay/{payment_id}pay_page::pay_page (HTML)

Handlers that return AppError respond with JSON:

{"error":"<Display of the AppError>"}
VariantStatusDisplay prefix
InvalidConfig400invalid configuration:
InvalidRequest400invalid request:
NotFound404not found:
StartupIntegrity400startup integrity check failed:
Wallet500wallet error:
Database500database error:
Io500io error:
Json500json error:
Http500http error:
TimeFormat500time formatting error:
TimeParse500time parsing error:

GET /health returns Database when the metadata query fails. POST /payment-session returns InvalidRequest for a bad payment_source, and Wallet or Database when minting an address fails. Identity failures are returned by PaymentService::initialize and run() before the socket listens. They use the same AppError display text, as a process error.

GET /health

Returns 200 and HealthResponse:

FieldJSON typeSource
okbooleanAlways true when the handler succeeds.
current_sync_statestringservice_metadata.current_sync_state.
detected_tip_heightnumber or nulllast_seen_tip_height when it is greater than 0, otherwise null.
last_scanned_heightnumber or nulllast_scanned_height when it is greater than 0, otherwise null.

The row inserted at first startup has current_sync_state "starting" and both heights 0, so both JSON heights are null.

The sync worker later writes one of these states:

StateWhen
catching_upA scan toward the sampled tip is in progress.
syncedThe last scanned height is at or past the sampled tip.
blocked_scanning_key_materialZCASH_UIVK is unset, so scanning keys cannot be built.
blocked_lightwalletd_endpointThe UIVK is set and LIGHTWALLETD_URL is unset.

record_sync_status stores whatever string it is given. Those four, plus the initial starting, are the ones the running service writes.

The unit test health_response_reports_persisted_sync_heights records state catching_up with tip 321 and scanned height 300, and the handler returns:

{
  "ok": true,
  "current_sync_state": "catching_up",
  "detected_tip_height": 321,
  "last_scanned_height": 300
}

POST /payment-session

Body JSON, content type application/json (a subtype with a +json suffix is also accepted by axum). PaymentSessionRequest:

FieldRust typeRequiredRole
payment_sourceStringyesMust equal zcash.
labelOption<String>noStored on the issued address.
memoOption<String>noStored, and copied into qr_text (base64url-encoded, as ZIP-321 requires) when non-blank.
messageOption<String>noStored, and copied into qr_text when non-blank.
amountOption<String>noStored as text, and copied into qr_text when non-blank. Only the pay page status parses it (see Payment links).
return_urlOption<String>noAbsolute http(s) URL the pay page returns the payer to once paid. Blank is treated as unset.

Unknown JSON fields are ignored. A missing payment_source, or a value of the wrong JSON type, never reaches the handler.

FailureStatusBody
Content-Type is not JSON415axum’s plain-text rejection
JSON syntax is invalid400plain text starting with Failed to parse the request body as JSON
JSON does not match the struct422plain text starting with Failed to deserialize the JSON body into the target type
payment_source is not zcash400{"error":"invalid request: payment_source must be 'zcash'"}
return_url is not an absolute http(s) URL, has user info, or is not on an allowed origin400{"error":"invalid request: return_url must ..."}
Address derivation or the database fails500{"error":"wallet error: ..."} or {"error":"database error: ..."}

Success is 200 and PaymentSessionResponse:

FieldMeaning
addressNewly derived unified address. Each successful call returns a different one. The test fresh_addresses_are_real_unified_addresses expects the string to start with u.
qr_textzcash:<address> plus an optional query.
payment_id32 lowercase hex characters (128 random bits) identifying this session’s pay page.
pay_url<PUBLIC_BASE_URL>/pay/<payment_id>, or /pay/<payment_id> when PUBLIC_BASE_URL is unset.

Query construction in build_qr_text, covered by tests in src/service.rs:

zcash:uaddr
zcash:uaddr?amount=0.12345678&memo=SW52b2ljZSBJTlYtMTAwMQ&message=Thanks%20for%20your%20purchase.

Order is amount, then memo, then message. A missing or whitespace-only field is omitted. The value that is encoded is the original string, not a trimmed copy. As ZIP-321 requires, memo is the unpadded base64url encoding of its UTF-8 bytes. For amount and message, unreserved characters stay as themselves (A-Z a-z 0-9 - _ . ~). Every other byte becomes % plus two uppercase hex digits.

The address is derived from the canonical UIVK with Orchard required, Sapling required, and the transparent receiver omitted. The app database also stores an Ironwood receiver whose bytes equal the Orchard receiver.

GET /payments/{payment_id} and GET /pay/{payment_id}

GET /payments/{payment_id} returns PaymentStatusResponse as JSON. GET /pay/{payment_id} returns the hosted pay page as HTML. Both return 404 NotFound for an id that isn’t 32 lowercase hex characters or isn’t known. Fields, statuses, and how to expose them are in Payment links and the pay page.

POST /dev/simulate-payment (testnet test hook)

Off unless ZCASH_DEV_SIMULATED_PAYMENTS=true on testnet with no LIGHTWALLETD_URL; otherwise 404. Body {"address": "<issued address>", "amount": "0.2"}. Records a fake mined receipt of amount ZEC for that address, then queues the normal webhook. Returns {"txid": "...", "total_received": "0.20000000"}. Two optional fields drive confirmation depth deterministically: confirmations (0 records a mempool receipt, n a receipt with n confirmations; omitted means fully confirmed) and txid (re-reports an earlier simulated transaction, for example at a deeper confirmations, instead of adding a new one; the same txid at another address is a separate receipt there, like another output of one transaction). An explicit confirmations below WEBHOOK_REPORT_CONFIRMATIONS is recorded but not counted or reported yet, like a scanned receipt. It exists so store plugins can be tested end to end without a chain or funds. Never enable it on a service that takes real orders: anyone who can reach it can mark an address paid.

Webhook

This is a request the service sends, not a route it serves. spawn_webhook_delivery_worker starts the loop only when both webhook_url and webhook_secret are set.

  • Method: POST
  • URL: ZCASH_WEBHOOK_URL
  • Header Content-Type: application/json
  • Header X-Signature: lowercase hex HMAC-SHA256
  • Body: the exact request_body_json stored when the delivery was queued

The HMAC key is ZCASH_WEBHOOK_SECRET as UTF-8 bytes. The message is the raw body. sign_body uses hmac with sha2::Sha256 and encodes the digest with hex::encode.

The stored JSON is serde_json::to_string of these fields, in this order:

FieldValue
event_idzcash:<address_id>:v<state_version>
payment_sourcezcash
addressissued_addresses.unified_address
total_receivedEligible zatoshis rendered as ZEC
observed_atRFC 3339 UTC time when the row was queued
received_by_confirmationsOnly when REQUIRED_CONFIRMATIONS is set: array of N+1 ZEC strings, see below

format_zat_as_zec divides the absolute zatoshis by 100_000_000 and prints the remainder in 8 digits. 12345678 zatoshis become 0.12345678. 100000000 become 1.00000000. A negative i64 gets a leading -.

received_by_confirmations element i sums the same active, eligible receipts that have a confirmation depth of at least i at the tip the address was last computed for (mempool receipts have depth 0). Element 0 therefore always equals total_received, and the array never increases from left to right. It is stored as address_totals.received_by_confirmations and copied into the delivery row, so retries resend the exact signed body.

state_version increments by one when current_total_zat changes, or when received_by_confirmations changes (a new block deepening a receipt, a reorg, or a mempool expiry). current_total_zat is the sum of value_zat over receipts for that address that are active and eligible_for_webhook. A delivery is inserted when dirty_state is dirty and the current total, or the current received_by_confirmations, differs from the newest outstanding or acknowledged one. Once every element equals element 0 the array stops changing, so no further deliveries are queued for that address until funds arrive or a reorg undoes a block. event_id is unique, and the insert is INSERT OR IGNORE.

claim_ready_webhook_delivery takes the oldest row that is queued, or retry_wait with next_attempt_at already due, ordered by observed_at then delivery_id. A newer queued delivery can be sent while an older one is still in retry_wait. Receivers should keep the highest state_version they have applied for an address. A later delivery of a lower version is an older total, including after a reorg where the newer total is smaller.

Eligibility:

  • Mined receipt: confirmation depth is tip_height - mined_height + 1 when tip_height >= mined_height, else 0. Eligible when the depth is >= webhook_report_confirmations.
  • Mempool receipt: eligible when webhook_report_confirmations == 0 and first_observed_at is strictly later than 24 hours before the check. The sync loop reconciles mempool receipts only in that zero-confirmation mode.

Delivery outcomes are in the integration guide. On startup, run() moves rows left in sending back to retry_wait with next_attempt_at set to the current time, then queues addresses whose dirty_state is still dirty.

Configuration

Config::from_env reads the process environment. Strings are trimmed. An empty string is treated as unset for ZCASH_UIVK, LIGHTWALLETD_URL, ZCASH_WEBHOOK_URL, and ZCASH_WEBHOOK_SECRET.

VariableDefaultAccepted values
LISTEN_ADDR0.0.0.0:8787 when PORT is also unsetUsed as the bind address when set. Wins over PORT.
PORT8787Used only when LISTEN_ADDR is unset. Bind address becomes 0.0.0.0:<port>.
ZCASH_NETWORKtestnettestnet or mainnet.
ZCASH_UIVKunsetUIVK text. See startup rules.
ZCASH_BIRTHDAY_HEIGHTunsetMust parse as u32.
LIGHTWALLETD_URLunsetPassed to the lightwalletd client. Required for scanning.
APP_DATA_DIRdata/zcash-paymentBase directory for the default paths below.
APP_DB_PATH<APP_DATA_DIR>/app.dbMust differ from WALLET_DB_PATH.
WALLET_DB_PATH<APP_DATA_DIR>/wallet.dbzcash_client_sqlite database.
LOG_DIR<APP_DATA_DIR>/logsDaily JSON file zcash-payment.log.
CATCH_UP_THRESHOLD_BLOCKS1Parses as u32, including 0.
CATCH_UP_BATCH_SIZE100u32, must be greater than 0.
SCAN_PARALLELISMnumber of CPUs the process may useusize, must be greater than 0. Caps the threads used to trial-decrypt compact blocks. 1 scans serially. See Deployment.
SYNC_POLL_INTERVAL_SECONDS5u64, must be greater than 0. Fallback delay when the mempool stream is unavailable.
ZCASH_WEBHOOK_URLunsetAbsolute http or https URL with a host.
ZCASH_WEBHOOK_SECRETunsetHMAC key. Delivery runs only when this and the URL are both set.
PUBLIC_BASE_URLunsetAbsolute http(s) URL with an optional path prefix and no query or fragment; trailing / removed. Prefix for pay_url. See Payment links.
ZCASH_DEV_SIMULATED_PAYMENTSfalsetrue/false/1/0. Enables POST /dev/simulate-payment. Rejected unless ZCASH_NETWORK=testnet and LIGHTWALLETD_URL is unset (no real scanning runs alongside it). For tests only. A database that ever recorded a simulated payment is marked, and the service refuses to start on it with LIGHTWALLETD_URL set; use a fresh database for real scanning.
RETURN_URL_ALLOWED_ORIGINSunsetComma-separated http(s) origins (no path) a session’s return_url must match. Unset allows any http(s) URL. See Payment links.
WEBHOOK_POLL_INTERVAL_SECONDS2u64, greater than 0.
WEBHOOK_RETRY_DELAY_SECONDS30u64, greater than 0. Base of the exponential backoff.
WEBHOOK_RETRY_MAX_DELAY_SECONDS300u64, greater than 0. Cap on the backoff.
WEBHOOK_MAX_ATTEMPTS8u32, greater than 0.
WEBHOOK_REPORT_CONFIRMATIONS0u32. 0 includes provisional mempool receipts.
FINALITY_CONFIRMATIONS100u32. Stored and used for checkpointing. Does not gate total_received.
REQUIRED_CONFIRMATIONSunsetu32 N, at most 100 and at most FINALITY_CONFIRMATIONS. Adds received_by_confirmations (N+1 elements) to webhooks and GET /payments/{id}. Requires WEBHOOK_REPORT_CONFIRMATIONS=0, because element 0 includes mempool receipts. Unset (the default, and every existing database) omits the field.
RUST_LOGinfotracing-subscriber EnvFilter directive.

A value that fails its parse or range check returns InvalidConfig and the process does not listen. The error strings include:

  • ZCASH_NETWORK must be 'testnet' or 'mainnet'
  • ZCASH_BIRTHDAY_HEIGHT must be a positive integer (the parse failed; a numeric u32, including 0, is accepted)
  • CATCH_UP_THRESHOLD_BLOCKS must be a non-negative integer
  • CATCH_UP_BATCH_SIZE must be a positive integer or CATCH_UP_BATCH_SIZE must be greater than zero
  • SCAN_PARALLELISM must be a positive integer or SCAN_PARALLELISM must be greater than zero
  • the same pair of messages for SYNC_POLL_INTERVAL_SECONDS, WEBHOOK_POLL_INTERVAL_SECONDS, WEBHOOK_RETRY_DELAY_SECONDS, WEBHOOK_RETRY_MAX_DELAY_SECONDS, and WEBHOOK_MAX_ATTEMPTS
  • WEBHOOK_REPORT_CONFIRMATIONS must be a non-negative integer
  • FINALITY_CONFIRMATIONS must be a non-negative integer
  • REQUIRED_CONFIRMATIONS must be a non-negative integer
  • REQUIRED_CONFIRMATIONS needs WEBHOOK_REPORT_CONFIRMATIONS=0, because received_by_confirmations[0] includes mempool receipts
  • REQUIRED_CONFIRMATIONS must be at most 100
  • REQUIRED_CONFIRMATIONS must not exceed FINALITY_CONFIRMATIONS
  • APP_DB_PATH and WALLET_DB_PATH must point to different files
  • ZCASH_WEBHOOK_URL must be a valid absolute http(s) URL; got ...
  • ZCASH_WEBHOOK_URL must be an absolute http(s) URL; got ...
  • ZCASH_WEBHOOK_URL must use http or https; got ...
  • ZCASH_WEBHOOK_URL must include a host; got ...
  • the same four messages for PUBLIC_BASE_URL, plus PUBLIC_BASE_URL must not include a query or fragment; got ...
  • ZCASH_DEV_SIMULATED_PAYMENTS must be 'true' or 'false'
  • ZCASH_DEV_SIMULATED_PAYMENTS is only allowed with ZCASH_NETWORK=testnet
  • ZCASH_DEV_SIMULATED_PAYMENTS cannot be combined with LIGHTWALLETD_URL

Setting the URL without the secret, or the secret without the URL, is allowed. The webhook worker stays off.

Named defaults live in src/constants.rs:

ConstantValue
APP_SCHEMA_VERSION2
WEBHOOK_REPORT_CONFIRMATIONS0
FINALITY_CONFIRMATIONS100
DEFAULT_CATCH_UP_THRESHOLD_BLOCKS1
DEFAULT_CATCH_UP_BATCH_SIZE100
DEFAULT_SYNC_POLL_INTERVAL_SECONDS5
DEFAULT_WEBHOOK_POLL_INTERVAL_SECONDS2
DEFAULT_WEBHOOK_RETRY_DELAY_SECONDS30
DEFAULT_WEBHOOK_RETRY_MAX_DELAY_SECONDS300
DEFAULT_WEBHOOK_MAX_ATTEMPTS8

WEBHOOK_REPORT_CONFIRMATIONS and FINALITY_CONFIRMATIONS in that table are the defaults. The environment variables of the same names override them for a new database. Once service_metadata exists, a later start whose values differ from the stored row fails. See the next section.

Startup rules

PaymentService::initialize and each sync iteration call ensure_wallet_identity.

On a database with no wallet identity yet:

  • The UIVK is ZCASH_UIVK, or, if that is unset, the UIVK read from the wallet database accounts table. If neither exists, startup fails with first startup requires ZCASH_UIVK unless the wallet DB already contains a canonical account.
  • The birthday is ZCASH_BIRTHDAY_HEIGHT, or the wallet database birthday. If neither exists, startup fails with first startup requires ZCASH_BIRTHDAY_HEIGHT unless the wallet DB already contains a canonical account.
  • When both the environment and the wallet database supply a UIVK, they must be equal (provided ZCASH_UIVK does not match the existing wallet DB identity).
  • The UIVK is stored as canonical_uivk.

On a later start:

  • A provided ZCASH_UIVK must equal canonical_uivk.
  • ZCASH_NETWORK must equal the stored network.
  • A provided ZCASH_BIRTHDAY_HEIGHT must equal the stored birthday. Omitting it is allowed.
  • A UIVK and birthday read from the wallet database must match the stored identity.

WalletView::decode then requires the canonical UIVK to decode for that network and to contain Orchard and Sapling (configured UIVK must contain Orchard and Sapling receivers). If the wallet database UIVK does not match the decoded canonical form, startup fails with wallet DB UIVK does not match the decoded canonical wallet view.

ensure_service_metadata inserts the confirmation settings on first start. A later start fails with stored service metadata confirmation settings do not match the configured constants when WEBHOOK_REPORT_CONFIRMATIONS or FINALITY_CONFIRMATIONS differs from that row.

REQUIRED_CONFIRMATIONS is deliberately not fixed at database creation. The array is derived from stored receipt heights rather than accumulated, so it can be rebuilt at any time. The setting is stored in service_metadata.required_confirmations (a nullable column added to existing databases on startup, NULL meaning off). When a start’s value differs from the stored one, every address’s array is rebuilt from its receipts in one transaction. That rebuild is silent: it does not bump state_version or queue webhooks, so changing the setting does not flood your endpoint. The next real change for an address (new funds, a new block deepening a receipt, a reorg) carries the new shape. Turning it off makes the field disappear from later events.

The scan loop builds keys from the environment variable ZCASH_UIVK on every iteration, not from the stored identity alone. Lightwalletd calls in src/scanner.rs request blocks by height, the latest block, tree state, the mempool stream, mempool transaction ids, and raw transactions by id. Those requests do not send the UIVK. Decryption uses scanning keys built locally from that UIVK.

The service keeps one shared connection to lightwalletd. It sends HTTP/2 and TCP keepalives on it, bounds connect and unary requests with timeouts, and polls the chain tip every 15 seconds while it waits on the mempool stream. A connection that drops silently is replaced within about half a minute instead of stalling block and mempool scanning until the operating system gives up on it.

Crate entry points

src/lib.rs exports these modules: config, constants, db, error, logging, receipt_ingest, scanner, service, wallet_db, wallet_runtime, webhook_delivery, zcash.

The binary and an embedded host call the items below. Signatures match the source.

pub struct AppState {
    pub service: Arc<PaymentService>,
}

pub fn build_router(state: AppState) -> Router;
pub async fn run() -> Result<(), AppError>;
impl Config {
    pub fn from_env() -> Result<Self, AppError>;
}

impl PaymentService {
    pub fn initialize(config: Config) -> Result<Self, AppError>;
    pub fn issue_payment_session(
        &self,
        request: PaymentSessionRequest,
    ) -> Result<PaymentSessionResponse, AppError>;
    pub fn health_response(&self) -> Result<HealthResponse, AppError>;
}

pub fn build_qr_text(
    address: &str,
    amount: Option<&str>,
    memo: Option<&str>,
    message: Option<&str>,
) -> String;

pub fn spawn_wallet_sync_worker(config: Config);
pub fn spawn_webhook_delivery_worker(config: Config);
pub fn init_logging(config: &Config) -> Result<WorkerGuard, AppError>;

run is the path main uses. It calls Config::from_env, init_logging, PaymentService::initialize, the private webhook recovery, both spawn_* workers, build_router, and axum::serve with shutdown on Ctrl-C.

PaymentService’s fields are private. Config’s fields are public and match the environment table: listen_addr, network, startup_uivk, lightwalletd_url, birthday_height, wallet_db_path, app_db_path, log_dir, catch_up_threshold_blocks, catch_up_batch_size, sync_poll_interval_seconds, webhook_url, webhook_secret, webhook_poll_interval_seconds, webhook_retry_delay_seconds, webhook_retry_max_delay_seconds, webhook_max_attempts, webhook_report_confirmations, finality_confirmations, public_base_url, return_url_allowed_origins.

db, scanner, receipt_ingest, wallet_db, and zcash are public as well. They are the storage and scan implementation behind the HTTP routes. Their types and function signatures are the pub items in those files. This guide does not add behavior on top of them.