API reference
Everything you can call against the live deployment. REST endpoints, on-chain Anchor program, the stealth-address algorithm, and the locked cross-language test vector. For the project narrative see /docs.
Base URL & auth
| Public base URL | https://g-pay-dashboard.vercel.app/api |
| Auth header | X-API-Key: <institution key> |
| Demo key | g-p_demo_h6kj9d8s7g6f5d4 |
| Content-Type for POST | application/json |
/api/*to the gateway over a private connection. This keeps the browser on HTTPS and avoids mixed-content blocks without exposing the gateway's direct endpoint.REST endpoints
Six public endpoints, all behind X-API-Key auth.
/v1/receiving-addressGenerate a fresh stealth address for one payment. Records the deposit in Postgres at state pending.
{
"customer_id": "C-1234",
"amount_hint": "100000000", // string of digits, smallest units
"mint": "USDC", // metadata only in V1; SOL deposits use the demo flow
"expire_seconds": 3600, // 60 .. 2592000
"refund_addr_hex": "00".repeat(32) // 32-byte hex
}{
"deposit_id": "dep_xxxx",
"stealth_pubkey_hex": "a09243…",
"ephemeral_r_hex": "841a55…",
"view_tag": 115,
"expires_at": 1778342668574
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/receiving-address" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"customer_id":"C-1234","amount_hint":"100000000","mint":"USDC","expire_seconds":3600,"refund_addr_hex":"0000000000000000000000000000000000000000000000000000000000000000"}'/v1/payment-status/:idRead a single deposit. on_chain_* fields are populated once the indexer has matched the on-chain Deposit account.
{
"deposit_id": "dep_xxxx",
"state": "pending" | "approved" | "rejected" | "released" | "refunded" | "expired",
"amount_hint": "100000000",
"stealth_pubkey_hex": "a09243…",
"view_tag": 115,
"expires_at": 1778342668574,
"on_chain_address": "CrKu5EYNCT9c…" | null,
"on_chain_amount": "100000000" | null,
"on_chain_state": "pending" | null
}curl "https://g-pay-dashboard.vercel.app/api/v1/payment-status/dep_xxxx" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"/v1/treasury/depositsAggregate counts by state for the calling institution.
{
"total": 3,
"by_state": { "pending": 1, "released": 2 }
}curl "https://g-pay-dashboard.vercel.app/api/v1/treasury/deposits" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"/v1/treasury/deposits/list?limit=NPaginated list of full deposit records, newest first. Default limit 100, max 500.
{
"total": 1,
"items": [
{
"deposit_id": "dep_xxxx",
"customer_id": "C-1234",
"amount_hint": "100000000",
"mint": "SOL",
"stealth_pubkey_hex": "a09243…",
"view_tag": 115,
"state": "released",
"created_at": 1778338767824,
"expires_at": 1778342367823,
"on_chain_address": "CrKu5EYNCT…",
"on_chain_amount": "100000000",
"on_chain_state": "released",
"on_chain_observed_at": 1778338835946
}
]
}curl "https://g-pay-dashboard.vercel.app/api/v1/treasury/deposits/list?limit=20" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"/v1/releaseMark an Approved deposit as released in the gateway DB. Does NOT submit the on-chain release tx — pair with the demo endpoint or your own signer in V2.
{
"deposit_id": "dep_xxxx",
"target_addr_hex": "cd".repeat(32)
}{
"deposit_id": "dep_xxxx",
"state": "released",
"target_addr_hex": "cdcd…",
"note": "gateway tracks release; on-chain submission goes via gpay-cli or relayer"
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/release" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"deposit_id":"dep_xxxx","target_addr_hex":"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"}'/v1/refundMark a Rejected/Expired deposit as refunded in the gateway DB.
{ "deposit_id": "dep_xxxx" }{
"deposit_id": "dep_xxxx",
"state": "refunded",
"refund_addr_hex": "00…"
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/refund" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"deposit_id":"dep_xxxx"}'Demo endpoints devnet only
These endpoints execute real Solana devnet transactions on the operator's behalf using server-side keypairs (the demo wallet, two oracle signers). They exist so a hackathon judge can verify the full lifecycle without holding any keys, and they are scoped to the bundled demo institution only. In V2 they go away in favor of institution-side signing + outbound webhooks.
/v1/demo/initdevnetCreate a deposit record with judge-friendly defaults: 0.1 SOL, refund_addr pre-wired to the on-server demo wallet, 1-hour expiry.
{
"deposit_id": "dep_xxxx",
"stealth_pubkey_hex": "3d0a10…",
"ephemeral_r_hex": "048c87…",
"view_tag": 92,
"refund_pubkey": "E5sMsf…",
"expires_at": 1778343825242
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/demo/init" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"/v1/demo/simulate-paymentdevnetSubmit a real on-chain SOL deposit to the deposit's stealth address. The indexer picks it up within ~5s and webhooks the gateway.
{ "deposit_id": "dep_xxxx" }{
"stage": "simulate",
"signature": "3P1D5m…",
"deposit_pda": "AXhCPv…",
"explorer_tx": "https://explorer.solana.com/tx/3P1D5m…?cluster=devnet",
"explorer_account": "https://explorer.solana.com/address/AXhCPv…?cluster=devnet"
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/demo/simulate-payment" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"deposit_id":"dep_xxxx"}'/v1/demo/attestdevnetTwo oracles sign an attestation with the given verdict. Threshold (2-of-3) is reached in one call so the on-chain state moves immediately.
{
"deposit_id": "dep_xxxx",
"verdict": "clean" | "dirty"
}{
"stage": "attest",
"verdict": "clean",
"signatures": ["2HUEki…", "2KR9sW…"],
"explorer_txs": ["https://…", "https://…"]
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/demo/attest" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"deposit_id":"dep_xxxx","verdict":"clean"}'/v1/demo/releasedevnetSubmit the on-chain release for an Approved deposit. Generates a fresh target pubkey on the server and moves the lamports there.
{ "deposit_id": "dep_xxxx" }{
"stage": "release",
"signature": "61guN7…",
"target": "6vGTSK…",
"explorer_tx": "https://…",
"explorer_target": "https://explorer.solana.com/address/6vGTSK…?cluster=devnet"
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/demo/release" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"deposit_id":"dep_xxxx"}'/v1/demo/refunddevnetSubmit the on-chain refund for a Rejected/Expired deposit. Sends to the captured refund_addr.
{ "deposit_id": "dep_xxxx" }{
"stage": "refund",
"signature": "...",
"refund_target": "E5sMsf…",
"explorer_tx": "https://…"
}curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/demo/refund" \
-H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4" \
-H "content-type: application/json" \
-d '{"deposit_id":"dep_xxxx"}'Anchor program
| Program ID | 75HuPfb2n7SD7KtcQnVpCW5SVN3RP9gZ9vTXP4D4ha6C ↗ |
| Source | programs/quarantine-vault |
| Cluster | devnet |
| Anchor | 1.0.x · SBF target · LiteSVM tests |
Instructions (8)
| Name | Signs | Effect |
|---|---|---|
initialize_vault | authority | Creates the Vault PDA. Stores oracle_set + min_attestations. |
deposit | depositor | Locks SOL in a per-deposit PDA. State = Pending. PDA seed: ['deposit', vault, stealth_pubkey, ephemeral_r]. |
deposit_token | depositor | SPL Token / Token-2022 variant. Tokens go to a per-deposit escrow_token_account whose authority is the Deposit PDA. |
attest | oracle ∈ oracle_set | Records one Attestation. clean_count ≥ threshold → Approved; dirty_count ≥ threshold → Rejected. |
release | deposit.release_authority | Approved → Released. Lamports move to a target pubkey. |
release_token | deposit.release_authority | SPL variant. token::transfer_checked from escrow → target token account. |
refund | any caller | Rejected | Expired | (Pending past expire_at) → Refunded. Sends to deposit.refund_addr. |
refund_token | any caller | SPL variant. Validates refund_target_token_account.owner == deposit.refund_addr. |
Account types
| bump | u8 | |
| authority | Pubkey | |
| oracle_set | Vec<Pubkey> | max_len 16 |
| min_attestations | u8 | m of n threshold |
| paused | bool | |
| deposit_count | u64 |
| bump | u8 | |
| vault | Pubkey | |
| stealth_pubkey | Pubkey | off-chain receive address |
| ephemeral_r | [u8; 32] | sender's ephemeral pubkey R |
| view_tag | u8 | |
| mint | Pubkey | Pubkey::default for SOL deposits |
| amount | u64 | |
| depositor | Pubkey | |
| refund_addr | Pubkey | |
| release_authority | Pubkey | |
| created_at | i64 | |
| expire_at | i64 | |
| state | DepositState | |
| attestations | Vec<Attestation> | max_len 16 |
| clean_count | u8 | |
| dirty_count | u8 |
Enums & PDA seeds
DepositState = Pending | Approved | Rejected | Released | Refunded | Expired
AmlVerdict = Clean | Dirty
vault seeds = ["vault", authority]
deposit seeds = ["deposit", vault, stealth_pubkey, ephemeral_r]
escrow_token seeds = ["escrow_token", deposit] // only for SPL depositsStealth-address derivation
Cryptonote-style two-key scheme on Curve25519. The institution publishes spend_pub and view_pub; private spend_priv stays in HSM (V2), view_priv goes to the indexer for scanning.
Domain separators (UTF-8 bytes):
DOMAIN_SHARED = "g-pay/stealth/shared/v1"
DOMAIN_OFFSET = "g-pay/stealth/offset/v1"
DOMAIN_VIEW_TAG = "g-pay/stealth/view-tag/v1"
Sender (per payment, gateway computes this for the customer):
r = Scalar::from_bytes_mod_order_wide(random 64 bytes)
R = r · G // ephemeral_r
shared = sha512(DOMAIN_SHARED || (r · view_pub).compress())[..32]
offset = Scalar::from_bytes_mod_order_wide(
sha512(DOMAIN_OFFSET || shared || nonce_le8))
P = spend_pub + offset · G // stealth pubkey
view_tag = sha512(DOMAIN_VIEW_TAG || shared)[0] // 1-byte tag
Recipient (indexer scans every observed Deposit account):
shared' = sha512(DOMAIN_SHARED || (view_priv · R).compress())[..32]
if sha512(DOMAIN_VIEW_TAG || shared')[0] != on-chain view_tag: skip
P' = spend_pub + scalar_from_hash(DOMAIN_OFFSET || shared' || nonce) · G
match = (P' == on-chain stealth_pubkey)
Spend (only the spend_priv holder can do this):
spend scalar = (spend_priv + offset) mod Lsharedvalue as the rest of the derivation, so it doesn't skip the scalar multiplication. What it does skip is the second derivation step (recomputing P'with point addition) when the tag doesn't match — roughly halving the work for non-matching deposits.Cross-language test vector
Implemented twice and locked byte-exact across Rust and TypeScript:
spend_priv = 0102030405060708090a0b0c0d0e0f10
1112131415161718191a1b1c1d1e1f00
view_priv = a0a1a2a3a4a5a6a7a8a9aaabacadaeaf
b0b1b2b3b4b5b6b7b8b9babbbcbdbe00
r_seed (64B) = 21 22 … 60 (sequential bytes)
nonce = 7
⇒ spend_pub = 616e237719716e25ead63d831f9117f7
9b5aa05af8be30ff0eddb3dc43e8bdcf
view_pub = 3e97bbe3dad77cdbab3b9d7a5af96386
8b2ee668470874b566dad4a32076c98b
stealth_pubkey = 20fa85036bcc5661f62af10c241ee824
3e2543735e7e869c58df13b02f3c26c3
ephemeral_r = 21c24081dfbed643c24ca431092386e1
cb0830937d5b4f4cc0d6f366586338b0
view_tag = 0xbfSources: crates/stealth-core/tests/vectors.rs · apps/api-gateway/tests/vector.test.ts. Any algorithm change must update both sides at once and relock the vector.
Indexer & relayer
Long-running Rust binary inside the docker compose stack. Polls getProgramAccounts every GPAY_SCAN_INTERVAL_MS(default 5s), filters by Deposit account size, scans each candidate against the registered slice's view_priv using stealth-core, and POSTs matches to the gateway's internal /v1/internal/deposit-detected endpoint authenticated by GPAY_INTERNAL_SECRET. The internal route is blocked from the public internet by Caddy.
Axum HTTP service inside the same stack. Exposes /v1/submit which decodes a base64-encoded VersionedTransaction, runs per-institution admission (token-bucket rate limit + monthly USDC fee cap + suspension), cosigns as fee payer, and submits to RPC. The V1 demo flow does not use it — the demo wallet pays its own fees via gpay-cli. V2 routes every customer and release transaction through the relayer so customers don't need SOL.
Errors
All non-2xx responses are JSON { error: string, detail?: any }.
| Status | When | Body |
|---|---|---|
400 | Zod validation rejected the body. detail is the Zod error path. | { "error": "invalid_body", "detail": { … } } |
401 | Missing or unknown X-API-Key. | { "error": "missing X-API-Key header" } | { "error": "invalid api key" } |
403 | Internal endpoint hit without GPAY_INTERNAL_SECRET (only reachable from inside the docker network anyway). | { "error": "forbidden" } |
404 | Deposit lookup missed (also returned for cross-institution lookups). | { "error": "not_found" } |
409 | State machine refused the action (release before approval, refund of an unrefundable state, etc.). | { "error": "not_approved", "state": "pending" } |
500 | Demo endpoint shell-out failed (CLI exit code != 0). detail carries the captured stderr. | { "stage": "release", "error": "…" } |