Introduction
VeilPay is a private payments application built on the Umbra Protocol on Solana. It lets anyone send and receive tokens with zero on-chain link between sender and recipient — using Groth16 ZK proofs and Arcium MPC encryption.
VeilPay exposes six primitives over a single shielded pool:
- Private Payment Links — shareable URLs that encode an ephemeral claim key. Both sender and recipient remain fully anonymous on-chain.
- Gift Cards — private gift card links with denomination presets, a personal message, and a gift card visual at claim. Built on top of payment links.
- Private Solana Pay — QR-code-based checkout for merchants. Customers pay via ZK proof; merchant receives without seeing the customer's wallet.
- Confidential Transfers — direct encrypted deposits to a known recipient's Umbra balance. The amount is hidden; addresses are known.
- Shield & Unshield — move tokens between your own public wallet and your private encrypted balance at any time.
- x402 Payments — HTTP 402 pay-per-request flows using Umbra stealth deposits. Invoiced in USDC; server receives without learning who paid.
VeilPay runs on mainnet by default. All five tokens — SOL, USDC, USDT, UMBRA, and CASH — are supported by the Umbra mainnet relayer.
Quickstart
Get VeilPay running locally in under five minutes.
Prerequisites
- Node.js 18+ and npm
- A Phantom or Solflare wallet browser extension
- A Supabase project (free tier works)
- A mainnet Solana RPC endpoint (Helius or QuickNode recommended; public endpoint works for testing)
Clone and install
git clone https://github.com/your-org/veilpay
cd veilpay/app
npm installConfigure environment
Copy the example env file and fill in your values:
cp .env.example .env.local# .env.local
NEXT_PUBLIC_NETWORK=mainnet
NEXT_PUBLIC_RPC_URL=https://your-rpc.helius.xyz/your-api-key
NEXT_PUBLIC_RPC_WS_URL=wss://your-rpc.helius.xyz/your-api-key
# Umbra services (auto-selected by NEXT_PUBLIC_NETWORK if omitted)
NEXT_PUBLIC_UMBRA_RELAYER_URL=https://relayer.api.umbraprivacy.com
NEXT_PUBLIC_UMBRA_INDEXER_URL=/api/indexer-proxy # proxied — do not change
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key # server-side only
# Link expiry
NEXT_PUBLIC_LINK_EXPIRY_DAYS=7Create the Supabase table
create table links (
id text primary key,
amount text not null,
token text not null,
amount_raw text not null,
decimals int not null,
created_at timestamptz not null,
expires_at timestamptz not null,
claimed boolean not null default false,
locked_to text,
claimed_by text,
claimed_at timestamptz
);
alter table links enable row level security;
create policy "Public read" on links for select using (true);Start the dev server
npm run dev
# → http://localhost:3000The first time you create a link the browser downloads ~100 MB of ZK circuit files (.zkey + .wasm). They are cached in the browser's Cache Storage under veilpay-zk-v2 and never re-downloaded.
Supported Tokens
VeilPay supports SOL and five SPL tokens on mainnet.
| Symbol | Name | Decimals | Mainnet Mint | Status |
|---|---|---|---|---|
| SOL | Solana | 9 | So1111…1112 | Live |
| USDC | USD Coin | 6 | EPjFWd…Dt1v | Live |
| USDT | Tether USD | 6 | Es9vMF…NYB | Live |
| UMBRA | Umbra | 6 | PRVT6T…meta | Live |
| CASH | CASH | 6 | CASHx9…CASH | Live |
How Umbra Works
Umbra is a privacy protocol on Solana that provides a shared shielded pool. Funds enter the pool as encrypted commitments and leave from unlinkable addresses. The privacy comes from four cryptographic layers:
Every UTXO claim requires a valid Groth16 proof that the claimant knows the secret randomness and owns the right key — without revealing either.
UTXOs are stored as Poseidon hashes of (amount, recipient_x25519, secret_randomness). Only the hash touches the chain.
The encrypted balance uses Arcium's multi-party computation network to hold token amounts in a ciphertext only you can read.
Each account has an X25519 keypair registered on-chain. Senders encrypt UTXO details to the recipient's public key so only they can scan and find it.
The shielded pool as anonymity set
Privacy is proportional to the pool's anonymity set — the number of active UTXOs. When your UTXO leaves the pool alongside thousands of others, a passive observer cannot determine which withdrawal corresponds to which deposit. VeilPay uses a single shared pool for all tokens and all users, maximising this set.
Umbra program architecture
- 1User registration — Each user registers two X25519 keys on-chain (user-account key + master-viewing key). A ZK proof binds the Ed25519 wallet key to the X25519 keys.
- 2Deposit (UTXO creation) — The sender calls the Umbra program to insert a commitment into the indexed Merkle tree. The UTXO is addressed to the recipient's X25519 public key.
- 3UTXO scanning — The recipient's client decrypts incoming UTXOs using their X25519 private key via the indexer's ciphertext discovery mechanism.
- 4Claim (nullifier spend) — A ZK proof is generated proving knowledge of the UTXO's secret randomness. The nullifier is burned on-chain (double-spend prevention), and the balance enters the encrypted account.
- 5Withdrawal — The encrypted balance is moved to the user's public ATA via an Arcium MPC decryption and on-chain token transfer.
UTXOs & the Mixer
A UTXO (Unspent Transaction Output) in Umbra is a cryptographic commitment representing the right to claim a specific amount of tokens — without publicly linking that right to who created it.
What a UTXO encodes
Each UTXO is the Poseidon hash of three private values:
commitment = Poseidon(
amount, // token quantity (u64)
recipient_x25519, // recipient's curve25519 public key (32 bytes)
secret_randomness // 32 bytes of entropy known only to the depositor
)Only the commitment is stored on-chain. The inputs remain private and are transmitted to the recipient encrypted via ECDH over the recipient's X25519 key.
The indexed Merkle tree
Umbra stores commitments in an Indexed Merkle Tree — a binary Merkle tree where each leaf is a UTXO commitment. The tree root is stored on-chain and updated with each new deposit. The indexer tracks all leaves and provides Merkle proofs needed for ZK claims.
- Tree capacity: 2²⁰ leaves per tree (~1 million UTXOs). New trees are created as capacity is filled.
- Leaves are inserted sequentially — insertion order provides a privacy hint but not a direct link.
- The indexer exposes a read-only gRPC API for scanning UTXOs by X25519 key.
Nullifiers: preventing double-spends
When a UTXO is claimed, the ZK proof includes the nullifier — a deterministic hash derived from the UTXO's secret randomness and the claimant's key. The program burns the nullifier on-chain (writes it to a NullifierAccount PDA). Any attempt to re-claim the same UTXO produces the same nullifier and is rejected with error 0x6d64 NullifierAlreadyBurnt.
Ciphertext discovery (scanning)
Each UTXO's plaintext (amount + secret) is encrypted to the recipient's X25519 public key using ECDH + AES-256-GCM. The ciphertext is stored in the indexer alongside the commitment. The recipient fetches ciphertexts from the indexer and trial-decrypts them with their X25519 private key. A successful decryption reveals the amount and secret randomness needed to build the claim proof.
Receiver-claimable UTXOs vs. encrypted balance UTXOs
| Type | Who can claim | Requires ZK proof? | Used in |
|---|---|---|---|
| Receiver-claimable | Anyone with the ephemeral private key | Yes (Groth16) | Private payment links |
| Encrypted balance | The registered account holder only | No (direct MPC) | Confidential transfers, shield |
Encrypted Balances
An Encrypted Balance is a per-token, per-user ciphertext stored in a PDA (EncryptedTokenAccount) on-chain. Only the account owner can decrypt it using their X25519 private key in combination with Arcium's MPC network.
Arcium MPC
Arcium is a multiparty computation (MPC) network built on Solana. When your encrypted balance changes — via a deposit, claim, or withdrawal — the Umbra program emits an Arcium computation request. The Arcium nodes collectively evaluate an arithmetic circuit over the ciphertext, updating the balance without any node learning the plaintext amount.
Key properties:
- No single Arcium node can decrypt the balance — threshold security requires collusion of a supermajority.
- The balance update is verified on-chain via a succinct proof from the Arcium prover.
- The Umbra relayer pays Arcium computation fees on the user's behalf — users pay only Solana transaction fees.
Balance states
The encrypted balance SDK returns one of three states:
| State | Meaning | Can withdraw? |
|---|---|---|
none | Account PDA not yet initialised — no deposits received. | No |
mxe | Arcium computation in progress — balance updating. | No (wait ~30s) |
shared | Balance settled and ready. | Yes |
Viewing keys
The Master Viewing Key (MVK) is an X25519 keypair derived from the wallet's master seed. It is registered on-chain during account setup. Anyone holding the MVK can decrypt all incoming ciphertexts for that account — but cannot sign transactions or move funds. This enables selective disclosure to auditors without granting custody.
The VeilPay Audit tab lets you paste a claim URL hash and inspect the UTXO status — pending, in-transit, or delivered — without connecting a wallet.
Private Payment Links
Private links let a sender create a one-time claimable payment in the shielded pool. No recipient wallet address is needed at creation time — you share the URL and they claim it later from any device.
How it works
- 1Generate ephemeral keypair — A fresh Ed25519 keypair is generated in-browser. The private key never touches the server.
- 2Fund ephemeral — 0.02 SOL is sent from the sender to the ephemeral address to cover Umbra registration rent (
EPHEMERAL_SOL_BUFFER). - 3Register ephemeral — The ephemeral address is registered with the Umbra program (X25519 keys on-chain, one ZK registration proof).
- 4Create receiver-claimable UTXO — The sender deposits funds into the Umbra Merkle tree, addressed to the ephemeral key.
- 5Encode URL — The ephemeral private key + token are base58-encoded into the URL hash:
/claim?lid=…&exp=…#<bs58Key>:<TOKEN>. The hash never leaves the browser.
Claim flow
- 1Parse URL hash — The claim page reads the hash fragment synchronously in useLayoutEffect — before first paint — and immediately clears it from the URL via history.replaceState.
- 2Scan the pool — The SDK scans the Umbra indexer for UTXOs addressed to the ephemeral X25519 key.
- 3Generate ZK proof — A Groth16 proof is computed in the browser (~15–60 s). ZK circuit files are cached after first download.
- 4Claim to encrypted balance — The relayer broadcasts the ZK claim transaction. The relayer pays fees.
- 5Withdraw — The ephemeral encrypted balance is swept to the recipient's public ATA.
- 6Mark claimed — The link is marked claimed in Supabase via a signed PATCH request.
Wallet-locked links
Optionally restrict claiming to a specific wallet. The claim page verifies the connected wallet matches via signMessage and an Ed25519 signature before the claim transaction is submitted. Suitable for high-value, pre-arranged payments.
Link expiry
Links expire after NEXT_PUBLIC_LINK_EXPIRY_DAYS days (default 7). The expiry is encoded in the URL query string (?exp=<ms>) and checked client-side before any network call. Expired links show a clear error state.
Gift Cards
Private gift cards are payment links with a dedicated creation UI and a gift card presentation at the claim page. The underlying ZK mechanism is identical to private payment links — the gift card layer is purely UX.
Creating a gift card
Navigate to /gift (or select the Gift Card tab on the Send page). Choose a denomination preset ($1 · $5 · $10 · $25 · $50 · $100 in USDC, or any amount in any supported token), add an optional From name, To name, and message. The message is encoded in the URL hash memo field and never touches the server.
Claim experience
When a recipient opens a gift card link (?type=gift in the URL) the claim page shows a sealed gift card with the message before any wallet interaction. Clicking Unwrap Gift triggers the standard ZK scan + claim flow. On success the card animates to a delivered state showing the received amount.
URL structure
/claim?type=gift&giftfrom=Alice&giftto=Bob&lid=<uuid>&exp=<ms>#<secret>:<TOKEN>:<message>?giftfrom and ?giftto are display names only — they are never treated as wallet addresses. ?to= (wallet lock) and ?giftto= are deliberately different params.
Private Solana Pay
Private Solana Pay is a QR-code checkout flow for merchants. The customer scans a QR, opens a browser-based VeilPay checkout page, connects their wallet, and pays via a shielded Umbra UTXO addressed to the merchant. Neither party's address appears linked on-chain.
Why not native Solana Pay wallet deep-links?
The standard Solana Pay Transaction Request format requires a wallet to POST and receive a pre-built transaction in ~2 seconds. Umbra's ZK proof takes 15–30 seconds and runs client-side. The QR therefore links to a VeilPay web checkout page (/pay/[id]) rather than a solana: URI.
Merchant setup flow
- 1Register — Navigate to
/merchantand connect the wallet you want to receive into. First-time merchants must complete a one-time Umbra registration (2–4 wallet signatures). - 2Generate QR — Enter an amount, token, and optional label. Click Generate QR. A unique payment request is stored in Supabase and a QR is rendered on screen.
- 3Customer pays — Customer scans QR → opens
/pay/[id]→ connects wallet → clicks Pay Privately → ZK proof runs → UTXO created for merchant. - 4Receive funds — The merchant page polls every 3 seconds. On confirmation it shows ✅. Funds land in the merchant's Umbra encrypted balance.
- 5Withdraw — Dashboard → Claim pending → funds move into encrypted balance → Withdraw to move to public wallet.
Payment request API
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/merchant-pay | None | Create a payment request |
| GET | /api/merchant-pay?id=<uuid> | None (public) | Fetch request status |
| GET | /api/merchant-pay?merchant=<addr> | None | List merchant's requests |
| PATCH | /api/merchant-pay?id=<uuid> | deposit_sig | Mark as paid |
Privacy properties
| Property | Result |
|---|---|
| Customer wallet | Not in QR, not linked on-chain to the merchant |
| Merchant wallet | Not in QR — only their Umbra commitment is referenced |
| Payment amount | Confidential inside the shielded pool |
| Transaction graph | Customer → Umbra Pool ← Merchant (no direct link) |
Confidential Transfers
Confidential transfers move tokens from your public wallet directly into a known recipient's Umbra encrypted balance. The amount is hidden on-chain via Arcium MPC. The transaction records your address and the recipient's PDA — not their wallet address — so the amount is private but the relationship is visible.
When to use confidential transfers vs. private links
| Private Link | Confidential Transfer | |
|---|---|---|
| Sender identity | Hidden | Visible |
| Recipient identity | Hidden | Visible (PDA only) |
| Amount on-chain | Hidden | Hidden |
| Recipient needs account | No | Yes — must have VeilPay account |
| Claim step required | Yes | No — instant arrival |
| Best for | Fully anonymous sends, tips, anonymous invoices | Business payments, payroll, recurring known-party sends |
How it works
VeilPay calls getPublicBalanceToEncryptedBalanceDirectDepositorFunction from the Umbra SDK. This creates a deposit transaction that triggers an Arcium MPC computation encrypting the token amount into the recipient's EncryptedTokenAccount PDA.
The recipient must have connected to VeilPay at least once so their Umbra account PDAs are initialised on-chain. The SDK will throw if they haven't registered.
Shield & Unshield
Shielding moves tokens from your public wallet into your own encrypted balance. Unshielding (withdrawal) reverses this — it moves your encrypted balance back to your public ATA. This is useful for accumulating privacy before a transfer, or for converting a public balance to a shielded one without involving another party.
Shield flow (public → encrypted)
- 1Check registration — VeilPay verifies your Umbra account PDAs exist. If not, it runs a ZK registration (wallet prompts 2–4).
- 2Ensure ATA — For SPL tokens, the associated token account is created if missing.
- 3Deposit to self — Calls
getPublicBalanceToEncryptedBalanceDirectDepositorFunctionwith your own address as recipient. Your public tokens are locked and your encrypted balance increases.
Shielding SOL requires no ATA — SOL is handled natively. Shielding SPL tokens may trigger one extra wallet prompt to create the token account.
Unshield flow (encrypted → public)
- 1Enter amount — From the Dashboard, click Withdraw on any token with a non-zero encrypted balance and enter the amount in the modal.
- 2Arcium decryption — VeilPay calls
getEncryptedBalanceToPublicBalanceDirectWithdrawerFunction. Arcium MPC decrements the encrypted balance. - 3On-chain transfer — The Umbra program transfers tokens from the program's escrow to your public ATA.
From the Dashboard UI
The Dashboard shows both your Public Balance and Encrypted Balance side by side for every supported token. Each row has two action buttons:
- Shield — opens a modal to enter an amount (with MAX button), moves public → encrypted.
- Withdraw — opens a modal to enter an amount, moves encrypted → public.
Dashboard
The Dashboard (/dashboard) is your account hub. It shows one balance state at a time — encrypted or public — and lets you switch between them with an animated flip card.
Balance states
Two tabs sit above the flip card. Clicking the inactive tab triggers a 3D Y-axis card flip with a cipher scramble effect at the midpoint:
- Encrypted Balance — funds inside the Umbra shielded pool. Tap any token row to open the Withdraw modal (encrypted → public wallet).
- Public Balance — your regular on-chain wallet balance. Tap any token row to open the Shield modal (public wallet → encrypted pool).
Actions
| Action | Available on | Description |
|---|---|---|
| Withdraw | Encrypted face | Move tokens from encrypted balance to your public wallet |
| Claim pending | Encrypted face | Claim receiver-claimable UTXOs (from payment links or merchant payments) into your encrypted balance |
| Shield funds | Public face | Move tokens from your public wallet into your encrypted balance |
| Send | Public face | Navigate to the Send page to create a payment link |
Link Audit tab
The Dashboard has a second tab — Link Audit — where you can paste a claim secret to check payment status (pending / claimed / delivered). This is a receipt checker, not a compliance viewing key: it reveals the ephemeral address and amount but not sender or recipient identities.
Wallet auto-connect
VeilPay stores the last connected wallet name in localStorage and silently re-connects on page load using the Wallet Standard {'{ silent: true }'} connect option. Phantom (and most Wallet Standard wallets) will reconnect without a popup if the site was previously approved.
x402 Payments
The x402 protocol extends HTTP with a 402 Payment Required response carrying machine-readable payment instructions. VeilPay implements x402 using Umbra stealth deposits — so the payment is metered and private.
Protocol flow
- 1Request — Client sends GET/POST to a protected endpoint.
- 2402 response — Server returns a JSON invoice: amount, token, destination address, 32-byte
invoiceId. - 3Deposit — Client makes an Umbra stealth deposit to the server's Umbra address, embedding
invoiceIdin the proof instruction's optional-data field. - 4X-402-Payment header — Client retries with
X-402-Payment: x402 <proofTxSig>:<depositTxSig>:<invoiceIdHex>. - 5Server verifies — Server decrypts the proof payload via ECDH + AES-256-GCM, checks amount ≥ required, destination correct, invoiceId matches, signature not replayed.
- 6200 response — API content returned.
Invoice response format
VeilPay's built-in premium endpoint invoices 0.20 USDC. The invoice includes token metadata so the paying agent knows exactly which mint and decimal precision to use:
HTTP/1.1 402 Payment Required
Content-Type: application/json
{
"error": "Payment Required",
"invoice": {
"amount": 0.2,
"token": "USDC",
"mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"decimals": 6,
"destination": "<server_solana_address>",
"invoiceId": "<32_byte_hex>"
}
}Payment header format
X-402-Payment: x402 <proofTxSignature>:<depositTxSignature>:<invoiceIdHex>Proof instruction AES payload layout
Bytes 0– 7 : amount (little-endian u64)
Bytes 8–39 : destination (32-byte address)
Bytes 40–55 : generation idx (16 bytes)
Bytes 56–67 : domain sep. (12 bytes)@veilpay/x402-sdk
Drop-in Node.js package for accepting private x402 payments in any server framework.
Installation
npm install @veilpay/x402-sdkNext.js App Router
// app/api/premium/route.ts
import { createX402 } from "@veilpay/x402-sdk";
import { Connection } from "@solana/web3.js";
const x402 = createX402({
network: "mainnet",
connection: new Connection(process.env.RPC_URL!),
serverPrivateKeyBase58: process.env.X402_SERVER_PRIVATE_KEY!,
serverSolanaAddress: process.env.NEXT_PUBLIC_X402_SERVER_ADDRESS!,
});
export const GET = x402.nextjs(
{ amount: 0.2, token: "USDC" },
async (req) => Response.json({ data: "premium content" })
);Express
import express from "express";
import { createX402 } from "@veilpay/x402-sdk";
const x402 = createX402({ network: "mainnet", connection, serverPrivateKeyBase58: "…", serverSolanaAddress: "…" });
const app = express();
app.get("/premium",
x402.express({ amount: 0.2, token: "USDC" }),
(req, res) => res.json({ data: "premium content" })
);Client — automatic payment
import { x402Fetch } from "@veilpay/x402-sdk";
// On a 402 response, x402Fetch automatically creates an Umbra deposit
// and retries with the X-402-Payment header.
const res = await x402Fetch("https://yourapp.com/api/premium", {
wallet, // Wallet Standard Wallet
account, // WalletAccount
network: "mainnet",
});
const data = await res.json();Replay protection — Redis store
import type { ReplayStore } from "@veilpay/x402-sdk";
class RedisReplayStore implements ReplayStore {
constructor(private redis: Redis) {}
async has(sig: string): Promise<boolean> {
return (await this.redis.exists(`x402:seen:${sig}`)) === 1;
}
async add(sig: string): Promise<void> {
await this.redis.setex(`x402:seen:${sig}`, 86400 * 30, "1");
}
}
const x402 = createX402({ …, replayStore: new RedisReplayStore(redis) });API reference
| Function | Description |
|---|---|
createX402(config) | Factory. Returns an X402Instance for your server. |
x402.nextjs(opts, handler) | Wraps a Next.js App Router handler with x402 enforcement. |
x402.express(opts) | Returns an Express middleware. |
x402.handle(req, opts, handler) | Framework-agnostic handler (fetch-compatible Request). |
x402.verify(payment, amount, token) | Fully verifies an x402 payment. Returns { valid, reason }. |
x402.generateInvoice(amt, token) | Returns a fresh { invoiceId, invoiceIdHex } pair. |
x402.parseAuthHeader(header) | Parses X-402-Payment header → X402Payment | null. |
x402Fetch(url, opts) | Client helper — auto-pays 402 responses. |
new MemoryReplayStore() | In-memory replay store. Single-instance only. |
VeilPay Agent Skill
The VeilPay agent skill lets AI agents create and claim private payment links, perform confidential transfers, and handle x402 payments fully autonomously — no browser, no wallet popup. The agent signs transactions with its own stored keypair. ZK proofs run locally in Node.js.
Installation
The agent skill is part of the agent-skills repository. It requires snarkjs and specific peer dependencies to compute ZK proofs locally.
# Install the skill into your agent's workspace
npx skills add Bmzennn/agent-skills@veilpay
# Install peer dependencies (required for local ZK proofs)
npm install --legacy-peer-deps1. Wallet & Balance Management
Agents manage their own Solana keypair and query both public and encrypted balances.
# Generate a new agent wallet
node scripts/wallet.cjs create
# Show public SOL and token balances
node scripts/wallet.cjs balance
# Show shielded (encrypted) balances in the Umbra pool
node scripts/balance.cjs2. Private Payment Links
Generate or claim shareable URLs that encode an ephemeral claim key.
# Create a 1.5 SOL private link
node scripts/create-link.cjs --amount 1.5 --token SOL
# Claim a link into the agent's wallet (runs local ZK proof)
node scripts/claim-link.cjs --link "https://veilpayments.xyz/claim?lid=…#<secret>:SOL"3. Confidential Transfers
Send tokens directly to a recipient's encrypted balance. The amount is hidden on-chain via Arcium MPC.
# Send 100 USDC confidentially to a recipient address
node scripts/transfer.cjs --to <address> --amount 100 --token USDCThe recipient must have connected to VeilPay at least once to initialize their encryption keys.
4. Shielding & Withdrawals
Move funds between the agent's public wallet and its private shielded balance.
# Withdraw 0.5 SOL from encrypted balance to public wallet
node scripts/withdraw.cjs --token SOL --amount 0.5
# Withdraw ALL USDC to public wallet
node scripts/withdraw.cjs --token USDC --all5. x402 & Premium Data
Fulfill x402 "Payment Required" challenges to access premium API endpoints or content.
# Fulfill a specific x402 invoice
node scripts/pay-invoice.cjs '<invoice_json>'
# Fetch premium table data (automatically handles x402 payment flow)
node scripts/premium.cjs --table links
node scripts/premium.cjs --table payments6. Utilities & Auditing
Tools for checking link status and auditing the agent's payment history.
# Check if a link is still active or already claimed
node scripts/check-link.cjs --link "<url>"
# List all x402 payments made by the agent
node scripts/list-payments.cjs
# Recover a stuck payment proof
node scripts/recover-payment.cjs --sig <tx_signature>Security & Performance
- Local Proving — ZK proofs (~100MB circuit files) are computed locally in Node.js. Files are cached at
~/.veilpay/zk-cache/. - Key Protection — Store the agent wallet with
chmod 600 ~/.veilpay/wallet.json. Never commit it to version control. - Double-Spend Prevention —
pay-invoice.cjscaches successful payments in a local ledger to prevent paying the same invoice twice.
Security Model
Claim note protection
The ephemeral private key lives only in the URL hash. VeilPay clears it from the URL via window.history.replaceState in useLayoutEffect — synchronously before first paint — minimising the window in which browser extensions can observe it.
Three runtime protections guard the link display:
- Blur by default — the key is blurred until the user clicks Reveal.
- Focus-loss re-blur — the key re-blurs immediately if the window loses focus (tab switch, screen share, alt-tab).
- 15-second auto-hide — a live countdown re-blurs the key after 15 s. The user must re-click to reveal again.
Clipboard hijack detection
Malicious extensions sometimes overwrite navigator.clipboard.writeText to silently replace copied addresses. VeilPay detects this by checking Function.prototype.toString on the clipboard method — extensions that override it typically do not also spoof the native-code signature. If tampering is detected, the Copy button is disabled and an amber warning is shown; the OS-level Web Share API remains available.
Server-side security
- All POST/PATCH requests to
/api/linksrequire an Ed25519 signature from the sender's wallet, verified server-side with@noble/curves/ed25519. - Signatures include a Unix timestamp — requests older than 5 minutes are rejected (replay protection).
- Rate limiting: 20 link creations per IP per minute.
- Supabase Row Level Security is enforced — the database is append-only for the anon key; only the service-role key can mark links claimed.
- The service-role key is server-side only (no NEXT_PUBLIC_ prefix) and is never sent to the browser.
Privacy Guarantees
What VeilPay can and cannot hide:
| Property | Private Links | Confidential Transfers | x402 |
|---|---|---|---|
| Sender identity | Hidden | Visible | Partial |
| Recipient identity | Hidden | PDA only | Server only |
| Transfer amount | Hidden | Hidden | Hidden |
| Transfer time | Visible | Visible | Visible |
| Token type | Visible | Visible | Visible |
| Link to existing balance | No link | PDA linked | No link |
Transaction timing and token type are always visible on-chain. For maximum privacy, avoid patterns that make timing-correlation attacks trivial (e.g. depositing and withdrawing the exact same amount within minutes of each other).
