Initial Bank28 reference backend — full neobank-on-AERE integration pattern

This commit is contained in:
architect 2026-05-03 16:09:09 +03:00
commit aa49d89558
5 changed files with 1957 additions and 0 deletions

71
README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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
View 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 fiatcrypto 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 fiatcrypto 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
View 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/**/*"]
}