@pollar/privy-adapter
v0.8.0
Published
Stateless HTTP proxy that lets Pollar sign Stellar transactions through your Privy account, without your Privy app secret leaving your infrastructure.
Readme
@pollar/privy-adapter
⚠️ Server-side only. This package starts an HTTP server with
@hono/node-serverand readsPRIVY_APP_SECRET/POLLAR_API_SECRETfrom the host environment. Importing it in a browser, React Native, or any other client-side bundle will leak credentials. The bundler will also blow up onnode:crypto/@hono/node-server. If you need a browser-side Privy integration, use the Privy client SDK directly — not this package.
Stateless HTTP proxy that lets Pollar sign Stellar transactions through your Privy account, without your Privy APP_SECRET ever leaving your infrastructure.
You install this package in your own backend, point Pollar at your adapter's URL, and it brokers each call to Privy on demand. The adapter holds no state, has no database, and exposes a small set of HTTP endpoints authenticated with a single Bearer token issued by Pollar.
Install
npm install @pollar/privy-adapterRequires Node 20+.
Quick start
import { createPollarPrivyAdapter } from '@pollar/privy-adapter';
const adapter = createPollarPrivyAdapter({
getCredentials: async () => ({
appId: process.env.PRIVY_APP_ID!,
appSecret: process.env.PRIVY_APP_SECRET!,
}),
pollarApiSecret: process.env.POLLAR_API_SECRET!,
network: 'mainnet',
port: 3001,
});
await adapter.start();With AWS Secrets Manager
getCredentials is async, so any secret manager works.
The secret must be stored as JSON {"appId": "...", "appSecret": "..."} — the example below calls JSON.parse directly on SecretString.
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { createPollarPrivyAdapter } from '@pollar/privy-adapter';
const sm = new SecretsManagerClient({ region: 'us-east-1' });
const adapter = createPollarPrivyAdapter({
getCredentials: async () => {
const result = await sm.send(new GetSecretValueCommand({ SecretId: 'privy/credentials' }));
return JSON.parse(result.SecretString!);
},
pollarApiSecret: process.env.POLLAR_API_SECRET!,
network: 'mainnet',
});
await adapter.start();The credentials are cached for 5 minutes by default. If getCredentials returns a different appId/appSecret after the cache expires, the underlying Privy client is rebuilt automatically — so you can rotate APP_SECRET without redeploying.
Graceful shutdown
const adapter = createPollarPrivyAdapter({
/* ... */
});
await adapter.start();
const shutdown = async () => {
await adapter.stop();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);Configuration
interface PollarPrivyAdapterConfig {
// ── Required ───────────────────────────────────────────────────────────
// Async credential resolver. Called on first request and when the cache expires.
getCredentials: () => Promise<{ appId: string; appSecret: string }>;
// Bearer token Pollar uses to authenticate calls. Generate it once, set it
// here, and register it in your Pollar dashboard.
pollarApiSecret: string;
// Stellar network used to compute transaction hashes.
network: 'mainnet' | 'testnet';
// ── Optional (with defaults) ──────────────────────────────────────────
port?: number; // 3001
cacheTtlMs?: number; // 5 * 60 * 1000
requestTimeoutMs?: number; // 10_000
maxBodyBytes?: number; // 64 * 1024
// ── Observability hooks ───────────────────────────────────────────────
onWalletCreated?: (userId: string, address: string) => void;
onTransactionSigned?: (walletAddress: string) => void;
onError?: (error: Error, ctx: { endpoint: string; body: unknown }) => void;
}Endpoints
All endpoints except /health require Authorization: Bearer <pollarApiSecret>.
Responses share the Pollar envelope:
// success
{ "content": { /* payload */ }, "code": "<SUCCESS_CODE>", "success": true }
// error
{ "code": "<ERROR_CODE>", "success": false }
// error with extra context (Zod issues or upstream reason)
{ "code": "VALIDATION_ERROR", "success": false, "issues": { /* ... */ } }
{ "code": "WALLET_CREATION_FAILED", "success": false, "reason": "Privy API: ..." }| Method | Path | Body | Success code | HTTP |
| ------ | ---------------------------- | ---------------------------------- | ------------------------------ | ---- |
| GET | /health | — | PRIVY_ADAPTER_HEALTH_OK | 200 |
| POST | /wallets/create | { userId } | PRIVY_ADAPTER_WALLET_CREATED | 201 |
| POST | /wallets/create (existing) | { userId } | PRIVY_ADAPTER_WALLET_EXISTS | 200 |
| POST | /wallets/sign | { userId, walletAddress, txXdr } | PRIVY_ADAPTER_TX_SIGNED | 200 |
| GET | /wallets/:userId/address | — | PRIVY_ADAPTER_WALLET_ADDRESS | 200 |
/wallets/create is idempotent: if the user already has a Stellar wallet, the existing address is returned with code PRIVY_ADAPTER_WALLET_EXISTS.
/wallets/sign accepts a base64 transaction XDR. The adapter parses it, computes the transaction hash for the configured network, asks Privy to sign the hash (Stellar Tier 2: Ed25519 raw sign), assembles the DecoratedSignature, and returns the fully signed XDR.
Error codes
| Code | HTTP | When |
| ------------------------ | ---- | ---------------------------------------------------------------------------- |
| FORBIDDEN | 401 | Missing or wrong Bearer token; response carries WWW-Authenticate: Bearer … |
| VALIDATION_ERROR | 400 | Body schema mismatch or invalid JSON |
| VALIDATION_ERROR | 413 | Body exceeded maxBodyBytes — response carries reason: "body too large" |
| WALLET_NOT_FOUND | 404 | User has no Stellar wallet |
| WALLET_CREATION_FAILED | 502 | Privy upstream error during create |
| WALLET_LOOKUP_FAILED | 502 | Privy upstream error during wallet lookup |
| TX_INVALID_SIGNED_XDR | 400 | XDR could not be parsed, or transaction is a fee-bump (unsupported) |
| TX_SIGN_FAILED | 502 | Privy upstream error during sign |
| INTERNAL_SERVER_ERROR | 500 | Unexpected failure |
Security notes
- The adapter is the sole holder of
PRIVY_APP_SECRETin the request path. Pollar only ever sees the Bearer token you issue it. - Bearer comparison uses
crypto.timingSafeEqual(constant time). - The adapter holds no persistent state. An in-memory LRU caches
walletAddress → walletId(10 min TTL, max 1000 entries) to avoid extra Privy round-trips on hot paths; nothing else is retained. - Logs are off by default. Pipe
onErrorto your own logger.
License
MIT
