Base URL: /v1/accounting
This document is the narrative companion to the API. It explains how the flows fit together — the polling patterns, the auth choices, the status semantics — and points at the live OpenAPI spec for exact field shapes.
Exact request/response schemas (types, validation rules, defaults) are generated from the FastAPI app's Pydantic models. Three surfaces expose them:
| Surface | Where | Purpose |
|---|---|---|
| Committed spec | docs/openapi.json |
Authoritative snapshot. Pin SDK builds and contract tests against this. Regenerated via make openapi; CI fails on drift. |
| Swagger UI | /docs (running service) |
Interactive exploration, "Try it out" requests, browseable schemas. |
| ReDoc | /redoc (running service) |
Read-only single-page view of the same spec. |
If this markdown disagrees with docs/openapi.json, trust docs/openapi.json. Contributors who change a Pydantic model must run make openapi and commit the regenerated file — the CI lint job runs make openapi-check and fails on drift.
- JSON for all request and response bodies.
- Hex strings include the
0xprefix and are normalized to lowercase. - Amounts are integers in the token's base units (wei for ETH, smallest unit for ERC-20). Strings are accepted; scientific notation is parsed with
Decimalto preserve precision. - Signatures are EIP-712 typed-data signatures. User-signed accounting operations are defined in
solidity/contracts/EIP712SignatureVerifier.sol. - Nonces are per-operation. User-signed operations key nonces by recovered signer; service-signed lock operations key nonces by service. Always fetch the current nonce immediately before signing.
- Status codes — most submission endpoints return
200 OKon synchronous success,202 Acceptedwhen work continues in the background (currently onlyPOST /deposits/check),400for validation errors,401for missing/invalid auth,422for contract reverts,429for rate-limit,500for internal failures.
| Type | Fields |
|---|---|
Lock |
serviceAddress address, tokenId bytes32, amount uint256, expiry uint256, nonce uint256 |
ModifyLock |
lockId uint256, amount uint256, newExpiry uint256, nonce uint256 |
Transfer |
toAddress address, tokenId bytes32, amount uint256, nonce uint256 |
Withdraw |
tokenId bytes32, amount uint256, nonce uint256 |
Most write operations are authorized per-request by an EIP-712 signature embedded in the body — no session needed.
A subset of endpoints expose private state (per-user balances, locks, deposit address, history) and require an authenticated session token instead. Two flows produce one:
GET /auth/domain → which SIWE domain to sign
GET /auth/nonce?address=<user> → single-use SIWE nonce
POST /auth/login {siwe_message, sig} → siwe_token + JWT pair
The login response returns:
siwe_token— encrypted Sapphire AuthToken; pass viaX-SIWE-Tokenheader for direct on-chain confidential reads.jwt_access_token/jwt_refresh_token— passAuthorization: Bearer <jwt_access_token>for normal API auth.
Browser requests to /auth/nonce and /auth/login are origin-checked — they must come from a configured Privana SIWE origin. Non-browser clients (no Origin header) are accepted.
Backends that only receive a JWT from hosted auth can exchange that JWT for a private-read token with POST /auth/jwt/siwe-token.
For apps on other origins, use the hosted authorization page with PKCE:
GET /auth/authorize?client_id=…&redirect_uri=…&code_challenge=…&state=…&chain_id=…
→ HTML page; user signs SIWE on Privana origin
POST /auth/authorize {siwe_message, sig, …} → short-lived `code`
POST /auth/token {grant_type=authorization_code, code, code_verifier, …}
→ access_token + id_token + refresh_token
code_challenge_methodmust beS256.redirect_urimust exactly match a value registered for the client.http://localhostand loopback callbacks are allowed; everything else must behttps.id_tokenis client-scoped (audience = configured client audience orclient_id) — use it for third-party backend identity verification, not for Privana API calls.
| Endpoint | Auth | Auth header |
|---|---|---|
POST /deposits/address |
required | Authorization: Bearer … or X-SIWE-Token |
POST /deposits/check |
required | same |
GET /deposits/status/{id} |
required | same |
GET /deposits/pending |
required | same |
POST /funds/withdraw-from-lock |
required | same |
GET /balances/{token_id} |
required | same |
POST /balances/batch |
required | same |
GET /funds/locked |
required | same |
GET /funds/locked/total/{token_id} |
required | same |
GET /funds/expired |
required | same |
GET /history |
required | same |
POST /onramp/intent |
required | same |
POST /onramp/sign-url |
required | same |
GET /onramp/pending |
required | same |
POST /onramp/{transaction_id} |
required | same |
POST /onramp/webhook |
MoonPay webhook signature | Moonpay-Signature-V2 |
POST /auth/jwt/siwe-token |
required | Authorization: Bearer … |
POST /auth/jwt/logout, GET /auth/jwt/me |
required | Authorization: Bearer … |
| Everything else | none (signature-gated where applicable) | — |
Authorization and X-SIWE-Token are mutually exclusive — sending both yields 400.
GET /history returns one page of the authenticated user's on-chain activity history. Entries within a page are ordered oldest to newest. Query parameters are offset (default -1) and limit (default 50, max 100). Non-negative offsets are 0-indexed page numbers from the oldest entries; negative offsets select pages from the end (-1 is the latest page, -2 the previous page).
| Field | Description | History kind type(s) |
|---|---|---|
kind |
Entry type. Known values are deposit, withdraw, createLock, transferFromLockOut, transferFromLockIn, transferBalanceOut, transferBalanceIn, modifyLock, and unlockLock; undecoded entries return unknown. |
all |
timestamp |
Entry timestamp. | all |
token_id |
Token identifier. | all decoded kinds |
amount |
Token amount as a decimal string. For modifyLock, this is the additional locked amount and can be 0 for expiry-only changes. For unlockLock, this is the amount returned to available balance. |
all decoded kinds |
chain_id |
Source chain for token_id, when known. |
all decoded kinds |
deposit_id |
Deposit identifier. | deposit |
counterparty |
Address payload for non-deposit entries: withdrawal destination for withdraw, lock service for createLock/modifyLock/unlockLock, the recipient for transferFromLockOut/transferBalanceOut, and the sender for transferFromLockIn/transferBalanceIn. |
all decoded kinds except deposit |
The deposit path is address-based: the contract derives a per-user address (one per chain_type × version), the user funds it directly, and the backend verifies + sweeps + credits.
1. POST /deposits/address → returns the user's per-user deposit address
(auth-gated — only the user can learn it)
2. user sends funds to that address on the source chain
3. POST /deposits/check → backend verifies the tx, returns 202
with status="pending" and a deposit_id
4. GET /deposits/status/{deposit_id} → poll until status="credited"
For external-wallet deposits the client does not know the tx hash (no
wallet callback, no webhook). GET /deposits/pending?chain_id=… scans
finalized source-chain ERC20 Transfer logs to the caller's deposit address and
returns uncredited candidates carrying exactly the fields POST /deposits/check consumes (chain_id, tx_hash, amount, log_index,
version). Candidates with a sweep already in flight come back as
status="processing" with their deposit_id — poll GET /deposits/status/{deposit_id} instead of re-submitting. The scan window
defaults to ~1 hour and is clamped to ~24 hours via lookback_blocks
(rounded up to full scan chunks); an optional token_address narrows the
scan to one registered token. Results are cached for up to 30 seconds, so a
just-submitted candidate may briefly reappear, possibly still marked
discovered — re-submitting it to /deposits/check is harmless (the
endpoint is idempotent). scanned_from_block reports the oldest block
actually examined: when a scan stops early at its internal candidate caps it
is higher than the requested window bottom, so treat only
[scanned_from_block, scanned_to_block] as covered. In the extreme case
where the caps trip inside the newest block, scanned_from_block is
scanned_to_block + 1: the interval is empty and no block is fully covered. Native transfers
emit no logs and are not discoverable — submit their tx hash to
/deposits/check directly.
Behavior notes:
POST /deposits/checkis idempotent. If the same(chain_id, tx_hash, log_index)is submitted twice, the second call short-circuits — returning200withstatus="credited"once the credit has landed on Sapphire, or202withstatus="pending"while the sweep is still running.- Sweeps run as background tasks. The processor returns within ~2-3s. Don't block on the response — poll
GET /deposits/status/{deposit_id}. - The status endpoint first consults an in-memory record (sweep in progress / failed) and then falls through to an on-chain
isDepositProcessedcheck. Records survive restarts via JSON persistence (seesrc/README.md). - The
min_depositfield of the address response is a per-chain map — clients should use it to gate the UI.
The fiat on-ramp reuses the deposit path: MoonPay delivers tokens straight to the user's per-user deposit address, and the existing verify → sweep → credit pipeline performs the actual credit. The on-ramp endpoints only correlate MoonPay purchase state with Privana deposits — the webhook never credits balances.
1. POST /onramp/intent → mint a signed Privana externalTransactionId
carrying provider, user, deposit, token,
chain, and canonical asset metadata
2. POST /onramp/sign-url → validate + sign the MoonPay widget URL
3. user completes the purchase in the MoonPay widget
4. POST /onramp/webhook → MoonPay reports status + on-chain tx hash;
the backend verifies and logs it
5. GET /onramp/pending → query MoonPay by externalCustomerId; clients
may also pass signed externalTransactionId
values for exact stale-session recovery
6. POST /deposits/check → normal deposit verification using the
MoonPay on-chain tx hash
7. GET /deposits/status/{id} → poll until status="credited"
| Endpoint | Auth | Purpose |
|---|---|---|
POST /onramp/intent |
required | Mint a signed intent pinning user_address, deposit wallet_address, token_id, chain_id, and MoonPay currency before the widget opens. |
POST /onramp/sign-url |
required | Validate an unsigned MoonPay widget URL (allow-listed host, expected apiKey, walletAddress = caller's deposit address, externalCustomerId = caller, signed Privana externalTransactionId, allow-listed currencyCode matching the intent) and return its HMAC signature. |
GET /onramp/pending |
required | Query MoonPay by the caller's externalCustomerId and return completed purchases whose signed Privana intent matches the caller and deposit address and carries enough data (token_id, chain_id, on_chain_tx_hash) for idempotent /deposits/check verification. Optionally accepts up to 10 signed externalTransactionId query values for exact stale-session recovery. |
POST /onramp/{transaction_id} |
required | Compatibility endpoint that validates caller-owned signed intent metadata and echoes the merged client payload without persisting state. |
POST /onramp/webhook |
MoonPay HMAC (Moonpay-Signature-V2) |
Verify and log MoonPay transaction updates. |
Behavior notes:
- Signed intents bind provider orders to Privana deposits. MoonPay's
externalTransactionIdcarries a signed Privana intent. The backend rejects tampered, wrong-provider, wrong-user, or wrong-deposit intents. Expiry blocks new widget URL signing; authenticated pending/compat recovery may decode expired intents because they still cannot authorize credit. - There is one provider-neutral wire format. The initial v1 format is canonical minified JSON authenticated with HMAC-SHA256; it includes an explicit provider and canonical provider asset. Generated v1 identifiers are at most 448 characters; the 512-character validation cap is defensive, not a provider limit. The current MoonPay adapter mints this format and rejects intents assigned to another provider; future provider adapters use the same codec.
- There is one signing-key ring per environment. At startup, the backend derives a 256-bit HMAC key from ROFL appd using
ONRAMP_INTENT_SIGNING_KEY_ID. Verification also accepts keys derived from comma-separatedONRAMP_INTENT_PREVIOUS_SIGNING_KEY_IDS. ROFL keeps each key stable for the same app and key ID across restarts and redeployments. Rotate by selecting a new current ID and retaining old IDs for 365 days after their last mint; the 24-hour intent TTL does not bound authenticated recovery. - MoonPay is the on-ramp transaction source.
/onramp/pendingdoes not depend on local on-ramp storage. It fetches MoonPay transactions byexternalCustomerIdand filters locally using the signed intent and delivery wallet. If the widget session was stale, the client can pass the signedexternalTransactionIdminted by/onramp/intent; the backend then uses MoonPay's exact external-id lookup and still returns only transactions whose signed intent matches the caller and deposit address. - Amounts come from provider reads. The signed intent carries only Privana binding metadata (provider, user, deposit wallet,
token_id,chain_id, canonical provider asset, nonce, and expiry). Base and quote currency amounts are returned only after the provider lookup. - The deposit path is still authoritative for credit.
/deposits/checkand/deposits/status/{deposit_id}remain the only balance-credit path and retain their existing idempotency. - Fail-closed configuration. Startup fails if any configured on-ramp intent key cannot be derived from ROFL. MoonPay operations independently require their relevant
MOONPAY_*keys.
1. GET /withdraw/nonce/{user} → current nonce, sign EIP-712 Withdraw(token_id, amount, nonce)
2. POST /withdraw → {token_id, amount, nonce, signature};
balance debited immediately and withdrawal queued
3. GET /withdraw/pending/{user} → poll; resolution + broadcast happens automatically
in the WithdrawalProcessor (~12s loop)
- The user's balance is debited at request time. Resolution is delayed by one block (simulation-attack protection) and runs in a background poll loop on the service side — frontends do not need to call
resolveWithdrawalthemselves. - Withdrawals are processed sequentially per destination chain to preserve nonce ordering. A failure on one chain does not block others.
GET /withdraw/{index}returns information about a specific withdrawal (resolved or not) — useful for status pages and audit views.
Locks are escrow primitives: a user locks funds for a service, and the service can spend within the locked amount until expiry; after expiry, anyone can release the funds back to the user.
| Endpoint | Signer | Purpose |
|---|---|---|
GET /funds/lock/nonce/{user} |
— | Fetch nonce for Lock EIP-712 |
POST /funds/lock |
user | Create a new lock ({service_address, token_id, amount, expiry, nonce, signature}) |
GET /funds/modify-lock/nonce/{user} |
— | Fetch nonce for ModifyLock EIP-712 |
POST /funds/modify-lock |
user | Add funds and/or extend expiry ({lock_id, amount, new_expiry, nonce, signature}). Pure no-ops are rejected. |
GET /funds/transfer-locked/nonce/{service} |
— | Fetch nonce for TransferFromLock EIP-712 |
POST /funds/transfer-locked |
service | Service consumes part or all of the lock to a destination user ({user_address, lock_id, to_address, amount, service_address, nonce, signature}) |
POST /funds/withdraw-from-lock |
user (auth) | User withdraws locked funds directly to an external destination ({to_address, lock_id, amount, nonce, signature} — the user is resolved from the auth header, not the body) |
POST /funds/unlock |
— | Unlock a single expired lock ({user_address, lock_id}, no signature; reverts if not yet expired) |
POST /funds/unlock-all-expired |
— | Unlock every expired lock for a user in one tx |
Reads:
GET /funds/locked(auth) — active locks; optional?service_address=…is a response-side filter only, not an authorization gate. Service backends should hit the contract'sgetServiceLocks(...)directly with their own SIWE token.GET /funds/locked/total/{token_id}(auth) — total locked across all the user's locks for a token.GET /funds/expired(auth) — every expired lock for the authenticated user.
Direct user-to-user balance transfer inside the accounting module — no on-chain settlement.
GET /funds/transfer/nonce/{user} → current Transfer nonce
POST /funds/transfer → {to_address, token_id, amount, nonce, signature}
The signature is an EIP-712 Transfer from the source user.
All require a session token (Authorization: Bearer … or X-SIWE-Token). The user is resolved from the token; never trust a user address in the body for reads.
| Endpoint | Returns |
|---|---|
GET /balances/{token_id} |
One token balance for the caller. |
POST /balances/batch |
Up to 100 token balances in one round trip. Body: {token_ids: string[]}. |
GET /funds/locked |
Active locks (optional ?service_address filter). |
GET /funds/locked/total/{token_id} |
Sum of locked amounts for one token. |
GET /funds/expired |
Locks past their expiry. |
GET /history |
On-chain activity history for the caller, defaulting to the latest page. |
A ContractLogicError from Sapphire on any of these is mapped to 401 Invalid or expired SIWE token — clients should reauth and retry.
| Endpoint | Returns |
|---|---|
GET /tokens |
List of every registered token. |
GET /tokens/{token_id} |
One token's metadata: type, chain, address, symbol, decimals. |
GET /withdraw/{index} |
Public withdrawal record. |
GET /withdraw/pending/{user} |
Pending withdrawals (public — knowing pending state isn't sensitive). |
GET /withdraw/nonce/{user} |
Current withdrawal nonce. |
GET /funds/transfer/nonce/{user} |
Current transfer nonce. |
GET /funds/lock/nonce/{user} |
Current createLock nonce. |
GET /funds/modify-lock/nonce/{user} |
Current modifyLock nonce. |
GET /funds/transfer-locked/nonce/{service} |
Current transferFromLock nonce. |
| Endpoint | Purpose |
|---|---|
GET /auth/domain |
Configured SIWE domain. |
GET /auth/nonce?address=… |
Single-use SIWE nonce. Browser-origin-checked. Rate-limited. |
POST /auth/login |
SIWE login → siwe_token + JWT pair. Browser-origin-checked. Rate-limited. |
POST /auth/jwt/refresh |
Rotate the refresh token; returns a fresh access/refresh pair. |
POST /auth/jwt/siwe-token (Bearer) |
Exchange a JWT access token for an encrypted SIWE token for on-chain private reads. |
POST /auth/jwt/logout (Bearer) |
Revoke one or all refresh tokens for the current user. |
GET /auth/jwt/jwks.json |
JWKS document for verifying issued JWTs. |
GET /auth/jwt/me (Bearer) |
Returns the authenticated address. Useful for client-side identity checks. |
GET /auth/authorize |
HTML auth page for cross-domain sign-in. Query params: client_id, redirect_uri, code_challenge, code_challenge_method=S256, chain_id, state, response_mode. |
POST /auth/authorize |
Verify SIWE on the Privana origin and mint a short-lived authorization code. Browser-origin-checked. Rate-limited. |
POST /auth/token |
Exchange an authorization code + PKCE verifier for access_token, id_token, refresh_token. |
| Code | Meaning |
|---|---|
200 OK |
Synchronous success — the response body has the result. |
202 Accepted |
Work continues in the background. Currently only POST /deposits/check when a sweep is in flight. Poll the corresponding status endpoint. |
400 Bad Request |
Pydantic validation, malformed hex/address, business rule violation (e.g. modify-lock no-op). |
401 Unauthorized |
Missing or invalid auth — bad SIWE token, expired JWT, both auth headers sent at once, or a MoonPay webhook signature that fails verification. |
403 Forbidden |
On-ramp record belongs to a different user or deposit address. |
404 Not Found |
Status check for an unknown deposit. |
413 Payload Too Large |
MoonPay webhook body exceeds the 1 MiB cap. |
422 Unprocessable Entity |
Contract revert (transaction submitted but the chain rejected it). The response detail carries the revert reason when available. |
429 Too Many Requests |
Auth rate-limiter tripped. Honour the Retry-After header. |
500 Internal Server Error |
Unexpected failure in the service layer. Errors are logged with stack traces — file an issue with the request id. |
503 Service Unavailable |
A required integration is not configured — MoonPay on-ramp keys (URL-signing, intent-signing, transaction-lookup, webhook) or the source-chain RPC used by GET /deposits/pending. |
For exact request/response shapes — including field types, constraints, and enum values — read docs/openapi.json (committed snapshot) or browse /docs against a running service. The Pydantic source models live in src/models/accounting.py and src/models/authorize.py.