Live on Solana devnet

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 URLhttps://g-pay-dashboard.vercel.app/api
Auth headerX-API-Key: <institution key>
Demo keyg-p_demo_h6kj9d8s7g6f5d4
Content-Type for POSTapplication/json
The public URL is a same-origin proxy: the dashboard's host rewrites /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.

POST/v1/receiving-address

Generate a fresh stealth address for one payment. Records the deposit in Postgres at state pending.

Request body
{
  "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
}
Response
{
  "deposit_id": "dep_xxxx",
  "stealth_pubkey_hex": "a09243…",
  "ephemeral_r_hex":    "841a55…",
  "view_tag": 115,
  "expires_at": 1778342668574
}
curl
bash
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"}'
GET/v1/payment-status/:id

Read a single deposit. on_chain_* fields are populated once the indexer has matched the on-chain Deposit account.

Response
{
  "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
bash
curl "https://g-pay-dashboard.vercel.app/api/v1/payment-status/dep_xxxx" \
  -H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"
GET/v1/treasury/deposits

Aggregate counts by state for the calling institution.

Response
{
  "total": 3,
  "by_state": { "pending": 1, "released": 2 }
}
curl
bash
curl "https://g-pay-dashboard.vercel.app/api/v1/treasury/deposits" \
  -H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"
GET/v1/treasury/deposits/list?limit=N

Paginated list of full deposit records, newest first. Default limit 100, max 500.

Response
{
  "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
bash
curl "https://g-pay-dashboard.vercel.app/api/v1/treasury/deposits/list?limit=20" \
  -H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"
POST/v1/release

Mark 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.

Request body
{
  "deposit_id": "dep_xxxx",
  "target_addr_hex": "cd".repeat(32)
}
Response
{
  "deposit_id": "dep_xxxx",
  "state": "released",
  "target_addr_hex": "cdcd…",
  "note": "gateway tracks release; on-chain submission goes via gpay-cli or relayer"
}
curl
bash
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"}'
POST/v1/refund

Mark a Rejected/Expired deposit as refunded in the gateway DB.

Request body
{ "deposit_id": "dep_xxxx" }
Response
{
  "deposit_id": "dep_xxxx",
  "state": "refunded",
  "refund_addr_hex": "00…"
}
curl
bash
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.

POST/v1/demo/initdevnet

Create a deposit record with judge-friendly defaults: 0.1 SOL, refund_addr pre-wired to the on-server demo wallet, 1-hour expiry.

Response
{
  "deposit_id": "dep_xxxx",
  "stealth_pubkey_hex": "3d0a10…",
  "ephemeral_r_hex":    "048c87…",
  "view_tag": 92,
  "refund_pubkey": "E5sMsf…",
  "expires_at": 1778343825242
}
curl
bash
curl -X POST "https://g-pay-dashboard.vercel.app/api/v1/demo/init" \
  -H "x-api-key: g-p_demo_h6kj9d8s7g6f5d4"
POST/v1/demo/simulate-paymentdevnet

Submit a real on-chain SOL deposit to the deposit's stealth address. The indexer picks it up within ~5s and webhooks the gateway.

Request body
{ "deposit_id": "dep_xxxx" }
Response
{
  "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
bash
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"}'
POST/v1/demo/attestdevnet

Two oracles sign an attestation with the given verdict. Threshold (2-of-3) is reached in one call so the on-chain state moves immediately.

Request body
{
  "deposit_id": "dep_xxxx",
  "verdict": "clean" | "dirty"
}
Response
{
  "stage": "attest",
  "verdict": "clean",
  "signatures":   ["2HUEki…", "2KR9sW…"],
  "explorer_txs": ["https://…", "https://…"]
}
curl
bash
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"}'
POST/v1/demo/releasedevnet

Submit the on-chain release for an Approved deposit. Generates a fresh target pubkey on the server and moves the lamports there.

Request body
{ "deposit_id": "dep_xxxx" }
Response
{
  "stage": "release",
  "signature": "61guN7…",
  "target": "6vGTSK…",
  "explorer_tx":     "https://…",
  "explorer_target": "https://explorer.solana.com/address/6vGTSK…?cluster=devnet"
}
curl
bash
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"}'
POST/v1/demo/refunddevnet

Submit the on-chain refund for a Rejected/Expired deposit. Sends to the captured refund_addr.

Request body
{ "deposit_id": "dep_xxxx" }
Response
{
  "stage": "refund",
  "signature": "...",
  "refund_target": "E5sMsf…",
  "explorer_tx": "https://…"
}
curl
bash
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 ID75HuPfb2n7SD7KtcQnVpCW5SVN3RP9gZ9vTXP4D4ha6C
Sourceprograms/quarantine-vault
Clusterdevnet
Anchor1.0.x · SBF target · LiteSVM tests

Instructions (8)

NameSignsEffect
initialize_vaultauthorityCreates the Vault PDA. Stores oracle_set + min_attestations.
depositdepositorLocks SOL in a per-deposit PDA. State = Pending. PDA seed: ['deposit', vault, stealth_pubkey, ephemeral_r].
deposit_tokendepositorSPL Token / Token-2022 variant. Tokens go to a per-deposit escrow_token_account whose authority is the Deposit PDA.
attestoracle ∈ oracle_setRecords one Attestation. clean_count ≥ threshold → Approved; dirty_count ≥ threshold → Rejected.
releasedeposit.release_authorityApproved → Released. Lamports move to a target pubkey.
release_tokendeposit.release_authoritySPL variant. token::transfer_checked from escrow → target token account.
refundany callerRejected | Expired | (Pending past expire_at) → Refunded. Sends to deposit.refund_addr.
refund_tokenany callerSPL variant. Validates refund_target_token_account.owner == deposit.refund_addr.

Account types

Vault
bumpu8
authorityPubkey
oracle_setVec<Pubkey>max_len 16
min_attestationsu8m of n threshold
pausedbool
deposit_countu64
Deposit
bumpu8
vaultPubkey
stealth_pubkeyPubkeyoff-chain receive address
ephemeral_r[u8; 32]sender's ephemeral pubkey R
view_tagu8
mintPubkeyPubkey::default for SOL deposits
amountu64
depositorPubkey
refund_addrPubkey
release_authorityPubkey
created_ati64
expire_ati64
stateDepositState
attestationsVec<Attestation>max_len 16
clean_countu8
dirty_countu8

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 deposits

Stealth-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 L
The view-tag is computed from the same sharedvalue 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       = 0xbf

Sources: 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

indexerIn production loop

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.

crates/indexer ↗
relayerDormant in V1

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.

crates/relayer ↗

Errors

All non-2xx responses are JSON { error: string, detail?: any }.

StatusWhenBody
400Zod validation rejected the body. detail is the Zod error path.{ "error": "invalid_body", "detail": { … } }
401Missing or unknown X-API-Key.{ "error": "missing X-API-Key header" } | { "error": "invalid api key" }
403Internal endpoint hit without GPAY_INTERNAL_SECRET (only reachable from inside the docker network anyway).{ "error": "forbidden" }
404Deposit lookup missed (also returned for cross-institution lookups).{ "error": "not_found" }
409State machine refused the action (release before approval, refund of an unrefundable state, etc.).{ "error": "not_approved", "state": "pending" }
500Demo endpoint shell-out failed (CLI exit code != 0). detail carries the captured stderr.{ "stage": "release", "error": "…" }