Analytics and tracking backend for bridge-id-sdk.
- Real-time Txn Tracking → Automated tracking from burn to mint across any CCTP-supported chain.
- Reliable Cancel Recovery → Direct
/track/statusendpoint lets the frontend reliably mark cancelled mints — bypassing the Circle SDK, which can fail silently. - Iris API Backup Poller → Checks Circle's Iris API every 2 minutes to auto-recover transactions stuck in any failed state (
mint_failed,attestation_failed,burned,attested). - Idempotency Guards → Prevents double-counting bridge stats and protects
mintedtransactions from being overwritten by late attestation events. - Analytics Ready → Aggregate volume and transaction stats per unique Bridge ID.
- Scalable Database → Uses Drizzle ORM with Neon (Serverless Postgres) for lightning-fast, zero-maintenance storage.
- SDK Repository: github.com/heyeren2/bridge-id-sdk
Your Bridge Frontend This Backend
──────────────────── ────────────────
User burns USDC
│
├─ sdk.trackBurn() ──► POST /track/burn
│ stores burn (status: "burned")
│
├─ sdk.trackAttestation() ──► POST /track/attestation
│ updates to "attested" or "attestation_failed"
│ ⚡ Protected: won't overwrite "minted"
│
├─ sdk.trackMint() ──► POST /track/mint
│ updates to "minted" or "mint_failed"
│ ⚡ Idempotent: skips if already minted
│
└─ (on cancel/fail) ──► POST /track/status ← NEW reliable fallback
force-sets "mint_failed" or "attestation_failed"
directly in DB, bypassing SDK entirely
│
▼
GET /activity/all
GET /transactions
GET /analytics/stats
▲
│
Your frontend queries these
Backup Poller: Runs every 2 minutes and checks Circle's Iris API for ALL stuck transactions (burned, attested, mint_failed, attestation_failed). If a mint is detected on-chain, the DB is updated to minted automatically.
| Layer | Tool |
|---|---|
| Server | Express + TypeScript |
| Database | Neon (serverless PostgreSQL) |
| ORM | Drizzle ORM |
| Backup | Circle Iris API poller |
| Deployment | Render |
Before starting, make sure you have accounts on:
git clone https://github.com/heyeren2/bridge-id-backend-template.git
cd bridge-id-backend-template
npm installCopy the example env file:
cp .env.example .envCreate a .env file from the example and fill in your details:
PORT=3001
DATABASE_URL=
# RPC URLs (Required for on-chain verification)
SEPOLIA_RPC_URL=
BASE_RPC_URL=
ARC_RPC_URL=| Variable | Description |
|---|---|
DATABASE_URL |
Neon/Postgres connection string. |
ARC_RPC_URL |
RPC endpoint for the Ark Network. |
SEPOLIA_RPC_URL |
RPC endpoint for Ethereum Sepolia. |
BASE_RPC_URL |
RPC endpoint for Base Sepolia. |
- Go to neon.tech and sign in
- Click New Project
- Name it
bridge-id-backend - Select region closest to your users (Frankfurt or US East recommended)
- Click Create Project
- On your Neon dashboard, click Connection Details
- Select Node.js from the dropdown
- Copy the connection string, it looks like:
postgresql://neondb_owner:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require
- Paste it as
DATABASE_URLin your.env
This creates the 4 required tables in your Neon database:
npm run db:pushYou should see:
✓ bridges table created
✓ transactions table created
✓ users table created
✓ bridge_stats table created
### 2.4 Register your Bridge ID
Your backend only processes tracking for "Registered" IDs. Run this script once to whitelist yours:
```bash
node scripts/register-bridge.js --id "your_bridge_id" --name "Project Name"
---
## Part 3: Deploy to Render
### 3.1 Push your repo to GitHub
Make sure your backend code is in a GitHub repository (private is fine):
```bash
git init
git add .
git commit -m "initial commit"
git remote add origin https://github.com/YOUR_USERNAME/bridge-id-backend.git
git branch -M main
git push -u origin main
- Go to render.com and sign in
- Click New → Web Service
- Connect your GitHub account and select your backend repo
- Configure the service:
| Setting | Value |
|---|---|
| Name | bridge-id-backend |
| Region | Frankfurt (EU) or Ohio |
| Branch | main |
| Runtime | Node |
| Build Command | npm install && npm run build |
| Start Command | npm start |
| Instance Type | Free |
In your Render service go to Environment and add all variables
from your .env file. Do NOT commit your .env to GitHub.
DATABASE_URL → your Neon connection string
SEPOLIA_RPC_URL → your Alchemy/Infura Sepolia RPC
BASE_RPC_URL → your Alchemy/Infura Base Sepolia RPC
ARC_RPC_URL → https://rpc.testnet.arc.network
Click Deploy. Once done your backend will be live at:
https://YOURBACKENDNAME.onrender.com
Verify it's running:
curl https://YOURBACKENDNAME.onrender.com/health
# → { "status": "ok" }Note: Render free tier spins down after 15 minutes of inactivity. The first request after idle takes ~50 seconds. Upgrade to a paid instance to avoid cold starts in production.
npm install bridge-id-sdkRun this once. This ID permanently links all your transactions in the backend.
npx bridgeidsdk --name "MyBridge" --address "0xYOUR_FEE_RECIPIENT_ADDRESS"Add the output to your frontend .env:
VITE_BRIDGE_ID=mybridge_a3f9c2
VITE_ANALYTICS_URL=https://your-backend.onrender.com
VITE_SEPOLIA_RPC=https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY
VITE_BASE_RPC=https://base-sepolia.g.alchemy.com/v2/YOUR_KEY
VITE_ARC_RPC=https://rpc.testnet.arc.networkimport { BridgeAnalytics } from "bridge-id-sdk"
const sdk = new BridgeAnalytics({
bridgeId: import.meta.env.VITE_BRIDGE_ID,
apiUrl: import.meta.env.VITE_ANALYTICS_URL,
rpcUrls: {
sepolia: import.meta.env.VITE_SEPOLIA_RPC,
base: import.meta.env.VITE_BASE_RPC,
arc: import.meta.env.VITE_ARC_RPC,
}
})Call these in your frontend's bridge status callbacks:
// After burn tx confirms on source chain
await sdk.trackBurn({
burnTxHash: "0x...",
wallet: userAddress,
amount: "100.00",
sourceChain: "sepolia",
destinationChain: "base",
})
// When attestation completes (or fails)
await sdk.trackAttestation({
burnTxHash: "0x...",
success: true, // or false if attestation failed
})
// When mint completes on destination chain
await sdk.trackMint({
burnTxHash: "0x...",
mintTxHash: "0x...",
success: true,
})
// When user CANCELS the mint in their wallet (reliable fallback — call in addition to SDK)
// This directly sets the DB status so the Remint button appears in the Activity tab.
await fetch(`${VITE_ANALYTICS_URL}/track/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
burnTxHash: "0x...",
bridgeId: VITE_BRIDGE_ID,
status: "mint_failed", // or "attestation_failed"
}),
})const status = await sdk.getStatus(burnTxHash)
// "burned" | "attested" | "attestation_failed" | "mint_failed" | "minted" | "not_found"
if (status.status === "attested" || status.status === "mint_failed") {
// Show Remint button in your UI
// Pass status.messageBytes + status.attestation
// to receiveMessage() on the destination MessageTransmitter
}const activity = await sdk.getUserActivity(walletAddress)
activity.transactions.forEach(tx => {
console.log(tx.sourceChain, "→", tx.destinationChain)
console.log(tx.amount, "USDC")
console.log(tx.status)
// Check mintTxHash as ground truth for success:
if (tx.mintTxHash) console.log("✅ Minted:", tx.mintTxHash)
})Base URL: https://your-backend.onrender.com
Records a burn transaction. Called by sdk.trackBurn().
Request body:
{
"burnTxHash": "0x...",
"wallet": "0x...",
"amount": "100.00",
"sourceChain": "sepolia",
"destinationChain": "base",
"bridgeId": "mybridge_a3f9c2"
}Response: { "success": true }
Updates attestation status. Called by sdk.trackAttestation().
Protected: will not overwrite a minted or completed status.
Request body:
{
"burnTxHash": "0x...",
"bridgeId": "mybridge_a3f9c2",
"success": true
}Response: { "success": true, "status": "attested" }
Completes the bridge and updates stats. Called by sdk.trackMint().
Idempotent: skips if the transaction is already minted to prevent double-counting.
Request body:
{
"burnTxHash": "0x...",
"mintTxHash": "0x...",
"bridgeId": "mybridge_a3f9c2",
"success": true
}Response: { "success": true, "status": "minted" }
Directly sets a transaction status in the DB. Used as a reliable fallback when the user cancels a mint or attestation — bypasses the SDK entirely.
Only allows setting recovery statuses (mint_failed, attestation_failed, attested).
Never downgrades a minted or completed transaction.
Request body:
{
"burnTxHash": "0x...",
"bridgeId": "mybridge_a3f9c2",
"status": "mint_failed"
}Response: { "success": true, "status": "mint_failed" }
When to use: Call this from your frontend whenever the user cancels a mint in their wallet. This ensures the Activity tab shows the Remint button immediately, even if the Circle SDK tracking call fails.
Returns paginated transaction list for a wallet.
Query params:
| Param | Required | Default | Description |
|---|---|---|---|
wallet |
Yes | → | Wallet address (0x...) |
limit |
No | 20 | Number of results |
offset |
No | 0 | Pagination offset |
Returns all recent transactions across all wallets (for global activity feed).
Returns full activity for a specific wallet address.
Example: GET /activity/0xabc123...
Returns aggregate stats for your bridge.
Query params: bridgeId (required)
Health check. Returns { "status": "ok" }
src/
server.ts » Express app, route registration, starts poller
db/
client.ts » Neon database connection
schema.ts » Drizzle table definitions
routes/
trackBurn.ts » POST /track/burn
trackAttestation.ts » POST /track/attestation (idempotency protected)
trackMint.ts » POST /track/mint (idempotent, uses "minted" status)
trackStatus.ts » POST /track/status (direct recovery endpoint)
transactions.ts » GET /transactions
activity.ts » GET /activity/:wallet + /activity/all
stats.ts » GET /analytics/stats
services/
txVerifier.ts » On-chain transaction verification
statusPoller.ts » Iris API backup poller (every 2 min, covers all stuck states)
chains/
config.ts » Chain names, IDs, and RPC URLs
drizzle.config.ts
.env.example
| Status | Meaning | Activity Tab |
|---|---|---|
burned |
Burn confirmed, waiting for attestation | ⏳ Processing |
attested |
Attestation ready, waiting for mint | ⏳ Processing |
attestation_failed |
Attestation timed out / Circle issue | |
mint_failed |
Mint cancelled or reverted | |
minted |
Mint confirmed, bridge complete | ✅ Success |
Note: The frontend treats
mintTxHashas the ground truth for success — if a transaction has amintTxHash, it displays as Success regardless of thestatusfield. This protects against stale status values in the DB.
MIT