Initial Bank28 reference backend — full neobank-on-AERE integration pattern
This commit is contained in:
commit
aa49d89558
71
README.md
Normal file
71
README.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Bank28 reference backend
|
||||
|
||||
Minimal Express server that demonstrates the **full Bank28-class neobank integration with AERE Network**, alongside a fiat BaaS provider (Striga, Dipocket, Modulr, Solaris, …).
|
||||
|
||||
This is a reference, not a production app. The intent is for the Bank28 build team to copy this skeleton and harden it — replace the in-memory `Map` with Postgres, replace the env-var private key with KMS-managed signing, add structured logging, etc.
|
||||
|
||||
## Architecture demonstrated
|
||||
|
||||
```
|
||||
Bank28 web/mobile app
|
||||
│
|
||||
│ Privy/Magic provisioned wallet (email/social → EVM address)
|
||||
▼
|
||||
┌──────────── this backend ────────────┐ ┌────── BaaS provider ──────┐
|
||||
│ /signup │◄──►│ KYC vendor │
|
||||
│ /webhooks/baas/kyc-cleared │ │ IBAN issuance │
|
||||
│ └─► aere.identity.addClaim() │ │ Card issuance │
|
||||
│ /users/:id/portfolio │ │ SEPA / SWIFT settlement │
|
||||
│ └─► aere.getPortfolio() │ └───────────────────────────┘
|
||||
│ /users/:id/earn │
|
||||
│ └─► returns tx instructions for Privy to sign
|
||||
│ /webhooks/onramp/deposit │◄── MoonPay / Transak / Ramp
|
||||
│ Live deposit watcher │◄── AERE Network (rpc.aere.network)
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## What it shows
|
||||
|
||||
1. **Wallet provisioning is non-custodial.** The user's AERE address is created client-side by Privy/Magic (email login). The backend just records it. No seed phrases, no key custody, no Bank28 liability for lost keys.
|
||||
2. **KYC attestation on-chain.** When the BaaS provider clears a user, this backend writes a `kyc-tier-1` claim to `AereIdentity` against the user's address. Smart contracts that gate features by KYC (lending, higher transfer limits, fiat off-ramp) check `hasValidClaim()` against the Bank28 attestor address.
|
||||
3. **Portfolio aggregation.** Multi-asset balance via `aere.getPortfolio()` — native AERE + WAERE + AereUSD in one call.
|
||||
4. **Yield without custody.** `/users/:id/earn` returns transaction *instructions* for the client to sign via Privy. The backend never holds the user's signing key.
|
||||
5. **Live deposit watcher.** Polls blocks, matches `tx.to` against the user registry, fires when a user receives AERE — same shape as Stripe webhooks for fiat.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# 1. Build the SDK first (sibling package)
|
||||
cd ../sdk-js && npm install && npx tsc
|
||||
|
||||
# 2. Run this backend
|
||||
cd ../bank28-reference-backend
|
||||
npm install
|
||||
OPS_PRIVATE_KEY=0x… npm run dev
|
||||
```
|
||||
|
||||
The `OPS_PRIVATE_KEY` is the **Bank28 attestor identity** — the address that signs KYC claims on-chain. In production this should be a hardware-backed or KMS-managed key, never an env var.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/signup` | Create a Bank28 user record. Body: `{email, aereAddress, baasUserId}`. |
|
||||
| `POST` | `/webhooks/baas/kyc-cleared` | BaaS provider webhook → writes on-chain KYC attestation. Body: `{baasUserId, reportHash, tier}`. |
|
||||
| `GET` | `/users/:id/portfolio` | Multi-asset balance + KYC status. |
|
||||
| `POST` | `/users/:id/earn` | Returns tx instructions for the client to sign (locks AERE in `AereStakingV2`). Body: `{tier, amountAere}`. |
|
||||
| `POST` | `/webhooks/onramp/deposit` | MoonPay/Transak webhook on fiat→crypto deposit completion. |
|
||||
| `GET` | `/healthz` | Health check. |
|
||||
|
||||
## Production checklist
|
||||
|
||||
- [ ] Replace the in-memory `users` Map with Postgres + Drizzle/Prisma.
|
||||
- [ ] Move `OPS_PRIVATE_KEY` to AWS KMS / HashiCorp Vault / GCP Cloud KMS — never an env var in production.
|
||||
- [ ] Verify webhook signatures from the BaaS provider (HMAC) and on-ramp provider before trusting payloads.
|
||||
- [ ] Add idempotency keys to all webhook handlers to prevent double-processing.
|
||||
- [ ] Add structured logging (pino) and metrics (Prometheus).
|
||||
- [ ] Add rate-limit middleware to all public endpoints.
|
||||
- [ ] Replace polling deposit watcher with WebSocket subscription (`aere.watchTransfersTo`).
|
||||
- [ ] Implement Travel Rule (FATF R.16) — Notabene if BaaS provider doesn't include it.
|
||||
- [ ] Set up monitoring on the ops wallet's gas balance (alert if < 1 AERE).
|
||||
- [ ] Audit — see `aerenew/audit-prep/INVARIANTS.md` for the on-chain side; commission a separate web-app audit for this server.
|
||||
1654
package-lock.json
generated
Normal file
1654
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "bank28-reference-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Reference backend showing how a Bank28-class neobank wires AERE Network as its crypto layer alongside a fiat BaaS provider. Demonstrates: per-user AERE wallet provisioning, KYC attestation on AereIdentity, fiat-to-crypto top-up, on-chain savings via AereStakingV2, deposit-watching webhook.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aere/sdk": "file:../sdk-js",
|
||||
"ethers": "^6.13.4",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
195
src/index.ts
Normal file
195
src/index.ts
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Bank28 reference backend — minimal Express server demonstrating how a
|
||||
* regulated neobank integrates AERE Network as its crypto layer.
|
||||
*
|
||||
* Routes:
|
||||
* POST /signup — create a Bank28 user (Privy wallet provisioning happens client-side)
|
||||
* POST /webhooks/baas/kyc-cleared — called by the BaaS provider when a user clears KYC; writes attestation on-chain
|
||||
* GET /users/:id/portfolio — multi-asset balance (AERE + WAERE + AereUSD)
|
||||
* POST /users/:id/earn — deposit AERE into AereStakingV2 (locked yield)
|
||||
* POST /users/:id/transfer — transfer AERE to another user
|
||||
* POST /webhooks/onramp/deposit — called by MoonPay/Transak when user's fiat→crypto deposit lands
|
||||
*
|
||||
* In production replace the in-memory store with Postgres, the OPS_PRIVATE_KEY
|
||||
* with KMS-managed signing, and the KYC valueHash with a hash of the actual
|
||||
* KYC report PDF (not the user's PII).
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { ethers } from 'ethers';
|
||||
import { AereClient } from '@aere/sdk';
|
||||
|
||||
// ─────────────────────────── Configuration ───────────────────────────
|
||||
|
||||
const OPS_PRIVATE_KEY = process.env.OPS_PRIVATE_KEY;
|
||||
if (!OPS_PRIVATE_KEY) throw new Error('OPS_PRIVATE_KEY missing — this key is the BANK28 ATTESTOR identity');
|
||||
const PORT = Number(process.env.PORT || 4400);
|
||||
|
||||
const aere = new AereClient({ privateKey: OPS_PRIVATE_KEY });
|
||||
const opsAddress = await new ethers.Wallet(OPS_PRIVATE_KEY).getAddress();
|
||||
console.log(`Bank28 ops wallet (KYC attestor): ${opsAddress}`);
|
||||
|
||||
// ─────────────────────────── In-memory user store ───────────────────────────
|
||||
|
||||
interface Bank28User {
|
||||
id: string;
|
||||
email: string;
|
||||
aereAddress: string; // provisioned by Privy on the client; sent up at signup
|
||||
baasUserId: string; // matches the user record at the BaaS provider (Striga / Dipocket / …)
|
||||
kycStatus: 'pending' | 'cleared' | 'rejected';
|
||||
kycReportHash?: string; // keccak256 of the BaaS-side KYC report PDF
|
||||
createdAt: number;
|
||||
}
|
||||
const users = new Map<string, Bank28User>();
|
||||
const usersByAereAddr = new Map<string, string>(); // address.toLowerCase() → userId
|
||||
|
||||
// ─────────────────────────── App ───────────────────────────
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/healthz', (_req, res) => res.json({ ok: true, opsAddress, userCount: users.size }));
|
||||
|
||||
/**
|
||||
* POST /signup
|
||||
* Body: { email, aereAddress, baasUserId }
|
||||
* - aereAddress comes from the client's Privy/Magic provisioning.
|
||||
* - baasUserId comes from the BaaS provider's user creation API.
|
||||
*/
|
||||
app.post('/signup', (req, res) => {
|
||||
const { email, aereAddress, baasUserId } = req.body || {};
|
||||
if (!email || !ethers.isAddress(aereAddress) || !baasUserId) {
|
||||
return res.status(400).json({ error: 'email + aereAddress + baasUserId required' });
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const user: Bank28User = {
|
||||
id, email, aereAddress, baasUserId,
|
||||
kycStatus: 'pending', createdAt: Date.now(),
|
||||
};
|
||||
users.set(id, user);
|
||||
usersByAereAddr.set(aereAddress.toLowerCase(), id);
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /webhooks/baas/kyc-cleared
|
||||
* Called by the BaaS provider when a user clears KYC.
|
||||
* Body: { baasUserId, reportHash, tier }
|
||||
* - reportHash is keccak256 of the verified report (provider-supplied; do NOT pass PII).
|
||||
* - tier is 1 | 2 (basic vs enhanced).
|
||||
*/
|
||||
app.post('/webhooks/baas/kyc-cleared', async (req, res) => {
|
||||
try {
|
||||
const { baasUserId, reportHash, tier } = req.body || {};
|
||||
if (!baasUserId || !/^0x[0-9a-fA-F]{64}$/.test(reportHash)) {
|
||||
return res.status(400).json({ error: 'baasUserId + reportHash (32-byte hex) required' });
|
||||
}
|
||||
const user = [...users.values()].find(u => u.baasUserId === baasUserId);
|
||||
if (!user) return res.status(404).json({ error: 'user not found' });
|
||||
|
||||
user.kycStatus = 'cleared';
|
||||
user.kycReportHash = reportHash;
|
||||
|
||||
// Write on-chain attestation: 1 year validity
|
||||
const expires = Math.floor(Date.now() / 1000) + 365 * 86400;
|
||||
const claimType = `kyc-tier-${tier === 2 ? 2 : 1}`;
|
||||
const tx = await aere.identity.addClaim(user.aereAddress, claimType, reportHash, expires);
|
||||
const receipt = await tx.wait();
|
||||
|
||||
res.json({ ok: true, claimType, txHash: tx.hash, blockNumber: receipt?.blockNumber });
|
||||
} catch (e: any) {
|
||||
console.error('kyc webhook', e);
|
||||
res.status(500).json({ error: e.shortMessage || e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /users/:id/portfolio
|
||||
* Multi-asset balance for the user's AERE wallet.
|
||||
*/
|
||||
app.get('/users/:id/portfolio', async (req, res) => {
|
||||
try {
|
||||
const user = users.get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'not found' });
|
||||
const p = await aere.getPortfolio(user.aereAddress);
|
||||
const claim = await aere.identity.hasValidClaim(user.aereAddress, 'kyc-tier-1', opsAddress);
|
||||
res.json({
|
||||
address: user.aereAddress,
|
||||
kycCleared: claim,
|
||||
aere: ethers.formatEther(p.aere),
|
||||
waere: ethers.formatEther(p.waere),
|
||||
aereUsd: ethers.formatEther(p.aereUsd),
|
||||
});
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /users/:id/earn
|
||||
* Body: { tier: 0..3, amountAere }
|
||||
* Locks AERE into AereStakingV2. Tiers: 0=30d/10%, 1=90d/15%, 2=180d/22%, 3=365d/30% APY.
|
||||
*
|
||||
* NOTE: The user must have approved Bank28 ops to spend their AERE — but since
|
||||
* AERE is the native token, this is a payable call. In a real flow the user
|
||||
* client signs this tx via Privy's embedded wallet; the backend just orchestrates.
|
||||
* Here we expose the orchestration shape only.
|
||||
*/
|
||||
app.post('/users/:id/earn', async (req, res) => {
|
||||
const user = users.get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'not found' });
|
||||
if (user.kycStatus !== 'cleared') return res.status(403).json({ error: 'kyc required' });
|
||||
res.json({
|
||||
instructions: {
|
||||
contract: aere.addresses.AereStakingV2,
|
||||
method: 'lock(uint256 tier)',
|
||||
tier: req.body.tier,
|
||||
valueWei: ethers.parseEther(String(req.body.amountAere)).toString(),
|
||||
hint: 'Sign this tx in the client via Privy/Magic. The backend cannot sign on behalf of the user (non-custodial).',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /webhooks/onramp/deposit
|
||||
* Called by MoonPay/Transak when a fiat→crypto deposit completes.
|
||||
* Body: { userId, amountWei, txHash }
|
||||
* - txHash is the on-chain transfer of USDC (or AERE) to the user's wallet.
|
||||
* - the backend just records it and fires app push.
|
||||
*/
|
||||
app.post('/webhooks/onramp/deposit', (req, res) => {
|
||||
const { userId, amountWei, txHash } = req.body || {};
|
||||
const user = users.get(userId);
|
||||
if (!user) return res.status(404).json({ error: 'not found' });
|
||||
console.log(`[onramp] user ${userId} received ${amountWei} via ${txHash}`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────── Live deposit watcher ───────────────────────────
|
||||
|
||||
/** Demo: log every native AERE deposit to any registered Bank28 user. */
|
||||
async function startDepositWatcher() {
|
||||
console.log('Starting deposit watcher…');
|
||||
await aere.watchTransfersTo('0x0000000000000000000000000000000000000000', () => {/* no-op */}); // warmup
|
||||
// We can't watch wildcard easily — instead, poll latest blocks and check tx.to against usersByAereAddr.
|
||||
let last = await aere.getBlockNumber();
|
||||
setInterval(async () => {
|
||||
const head = await aere.getBlockNumber();
|
||||
if (head <= last) return;
|
||||
for (let bn = last + 1; bn <= head; bn++) {
|
||||
const blk = await aere.provider.getBlock(bn, true);
|
||||
if (!blk) continue;
|
||||
for (const tx of blk.prefetchedTransactions ?? []) {
|
||||
const userId = tx.to ? usersByAereAddr.get(tx.to.toLowerCase()) : undefined;
|
||||
if (userId && tx.value > 0n) {
|
||||
console.log(`[deposit] user=${userId} +${ethers.formatEther(tx.value)} AERE tx=${tx.hash}`);
|
||||
// → push notification, credit Bank28 internal ledger, fire webhook to UI
|
||||
}
|
||||
}
|
||||
}
|
||||
last = head;
|
||||
}, 1500);
|
||||
}
|
||||
startDepositWatcher().catch(e => console.error('watcher fatal', e));
|
||||
|
||||
app.listen(PORT, () => console.log(`bank28 reference backend listening on ${PORT}`));
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user