Multi-tenant B2B stablecoin payments infrastructure for PSPs, fintechs, and remittance companies in LATAM. Accepts and reconciles USDC/USDT pay-ins on Avalanche, sweeps funds into institutional treasury, and executes outbound ERC-20 payouts.
Top 5 — Avalanche LATAM Institutional Hackathon · $300 prize
| Area | State |
|---|---|
| Pay-in / payout lifecycle | Ready |
| Multi-tenant clients (per-client API keys, data scoping, webhooks) | Ready |
| PostgreSQL persistence (direct, no in-memory state) | Ready |
| Structured observability (W3C trace, HMAC webhooks) | Ready |
| Pagination on list endpoints | Ready |
| Webhook delivery log | Ready |
| Auto-sweep after confirmation | Ready (opt-in via autoSweep) |
| CI (typecheck, lint, tests, build, migration check) | Ready |
| PaymentRouter smart contract | Deployed + verified on C-Chain mainnet — 0x91Bf4c06…149c2 |
| Treasury key management | Hot wallet — not production-safe for high value |
| Rate limiting | In-memory per instance — move to Redis for multi-instance |
Short answer: Ready for institutional pilots on Fuji (testnet) or production Avalanche with a hot wallet accepted. For high-value mainnet move the treasury key to KMS/HSM and rate limiting to Redis.
Clients (per-tenant API keys) Platform operator (admin key)
│ │
▼ ▼
[AvaSettle API] ─────────────── /v1/admin/clients
│
├── ClientsService ──── PostgreSQL (avasettle_clients)
├── PayinsService ──── viem (getLogs, ERC-20)
├── PayoutsService ──── viem (sendTransaction)
├── ReconciliationService (setInterval or manual, admin)
├── WebhookService ──── per-client URL + HMAC secret
└── BlockchainService ─── Avalanche C-Chain RPC
- Tenancy: every client (institution) is registered via the admin API and gets its own API key (only the SHA-256 hash is stored). All pay-ins, payouts, audit events, and webhook deliveries are scoped by
client_id. - Storage: PostgreSQL via
pg, no ORM, no in-memory state. The HD derivation counter is claimed atomically in SQL, so multiple instances never derive the same address. - Key derivation: BIP-44 HD wallet from
AVASETTLE_PAYIN_MNEMONIC. Each pay-in gets a unique EVM address. - Sweep: Treasury hot wallet top-ups derived addresses with AVAX, then sweeps ERC-20 to treasury.
- Smart contract: Optional
PaymentRouter.sol(Foundry) — payer approves USDC, callspayInvoice, funds go directly to treasury without per-address sweep.
cd avasettle
docker compose up --buildThis starts PostgreSQL 16 and the API (migrations run automatically) on
http://localhost:3001. Then verify the stack end to end:
API_BASE_URL=http://localhost:3001 AVASETTLE_ADMIN_API_KEY=dev-admin-key-change-me \
bash scripts/smoke.shTo use real Fuji pay-ins/payouts from Docker, put AVASETTLE_TREASURY_PRIVATE_KEY
and AVASETTLE_PAYIN_MNEMONIC in a .env file next to docker-compose.yml.
docker compose up -d postgres
pnpm install
cp .env.example .env # DATABASE_URL=postgres://avasettle:avasettle@localhost:5432/avasettle
pnpm start:devAPI available at http://localhost:3001.
Swagger UI at http://localhost:3001/docs.
curl -X POST http://localhost:3001/v1/admin/clients \
-H "x-avasettle-api-key: $AVASETTLE_ADMIN_API_KEY" \
-H "content-type: application/json" \
-d '{"name": "Fintech LATAM SA", "webhookUrl": "https://example.com/webhooks", "webhookSecret": "hook-secret"}'The response contains the client apiKey once — store it securely. The client then calls every /v1/* business endpoint with x-avasettle-api-key: <client key>.
Secrets go in .env. Everything else goes in config/avasettle.json (see config/avasettle.example.json).
| Variable | Required | Description |
|---|---|---|
AVASETTLE_ADMIN_API_KEY |
Yes | Platform-operator key for client management. Generate with openssl rand -hex 32. |
DATABASE_URL |
Yes | postgresql://user:pass@host:5432/avasettle |
AVASETTLE_TREASURY_PRIVATE_KEY |
Yes | Hot wallet private key for payouts and sweep top-ups. |
AVASETTLE_PAYIN_MNEMONIC |
Yes | BIP-39 mnemonic for deriving pay-in deposit addresses. |
Webhook URLs and signing secrets are configured per client via the admin API, not via env vars.
{
"network": "avalanche-fuji",
"port": 3001,
"rpcUrl": "https://api.avax-test.network/ext/bc/C/rpc",
"database": { "ssl": false, "maxConnections": 5, "autoMigrate": true },
"payin": {
"lookbackBlocks": 50000,
"defaultExpirationMinutes": 30
},
"assets": {
"enabled": ["USDC"],
"USDC": { "address": "0x5425890298aed601595a70AB815c96711a31Bc65", "decimals": 6, "maxPayoutAmount": "1000" }
},
"blockchain": { "minConfirmations": 2, "waitForReceipt": false },
"webhook": { "retryAttempts": 3 },
"autoReconcileIntervalSeconds": 30,
"autoSweep": false
}Env vars always override config file values.
pnpm start:dev # hot-reload dev server
pnpm build # compile TypeScript
pnpm start:prod # run compiled build
pnpm test # unit tests
pnpm test:e2e # integration tests
pnpm db:migrate # run pending SQL migrations
pnpm db:check # check migration status
pnpm contracts:build # compile PaymentRouter.sol with FoundryMigrations run automatically on startup when autoMigrate: true. For production-like deployments run them explicitly:
AVASETTLE_DATABASE_URL=postgresql://... pnpm db:migrate| Migration | Purpose |
|---|---|
001_init.sql |
Full schema: clients (multi-tenant), pay-ins, payouts, audit events, derivation counter, idempotency keys, webhook outbox + delivery log |
| Audience | Header | Source |
|---|---|---|
| Platform operator | x-avasettle-api-key: <admin key> |
AVASETTLE_ADMIN_API_KEY env var |
| Client (institution) | x-avasettle-api-key: <client key> or Authorization: Bearer <client key> |
Issued by POST /v1/admin/clients, rotatable via POST /v1/admin/clients/:id/rotate-key |
Admin endpoints: /v1/admin/clients*, /v1/reconciliation/run, /v1/reports/sweep-queue.
Everything else under /v1/* and /api/* requires a client key and only sees that client's data.
Avalanche-native contracts under contracts/ (Foundry). They are self-contained:
pnpm contracts:setup vendors the libs (forge-std + OpenZeppelin 5.x from
node_modules), then pnpm contracts:build / pnpm contracts:test work.
CI runs format-check + build + the full test suite (70 tests).
| Contract | Purpose |
|---|---|
PaymentRouter.sol |
Programmable invoice rail — payer settles a USDC invoice straight to treasury in one tx |
SettlementVault.sol |
On-chain payout rail — operators pay beneficiaries from the treasury, single or atomic batch |
PrivateSettlementRegistry.sol |
Experimental eERC roadmap step — settlement commitments (confidential amount/counterparty) with selective auditor verification |
pnpm contracts:setup # one-time: vendor forge-std + OpenZeppelin
pnpm contracts:test # forge test (70 tests)
# Deploy (Fuji shown; use --rpc-url avalanche for mainnet). Signing uses a
# Foundry keystore (--account/--sender); TREASURY|FUNDER and optional OWNER
# (multisig) come from the env. Use --private-key instead of --account if preferred.
TREASURY=… forge script contracts/script/Deploy.s.sol \
--rpc-url avalanche_fuji --account <keystore> --sender <addr> --broadcast --verify
FUNDER=… OPERATOR=… forge script contracts/script/DeploySettlementVault.s.sol \
--rpc-url avalanche_fuji --account <keystore> --sender <addr> --broadcast --verify
REGISTRAR=… forge script contracts/script/DeployPrivateSettlementRegistry.s.sol \
--rpc-url avalanche_fuji --account <keystore> --sender <addr> --broadcast --verifyAfter deployment set AVASETTLE_PAYMENT_ROUTER_ADDRESS (and, when used,
AVASETTLE_SETTLEMENT_VAULT_ADDRESS, AVASETTLE_PRIVATE_SETTLEMENT_REGISTRY_ADDRESS).
PaymentRouter highlights:
payInvoice(invoiceRef, token, amount, metadata)→invoiceId = keccak256(invoiceRef, token, amount); the id binds token + amount, so an invoice is consumed only by paying it exactly (no underpay / wrong-token invoice-locking)payInvoiceWithPermit(...)— EIP-2612: pay in a single tx with no priorapproveemergencyWithdraw(owner-only),minAmountper token,invoicePaiddouble-pay guard,Ownable2Stephandover,Pausable,ReentrancyGuard
SettlementVault highlights: pull-based & non-custodial (funds never rest in the contract), payout + atomic payoutBatch (≤256), operator/funder role split, per-payout replay guard, attributable PayoutExecuted events.
EVM target is
cancun(Avalanche C-Chain has the Durango opcodes: MCOPY, transient storage). Only non-rebasing, non-fee-on-transfer tokens may be whitelisted (USDC/USDT qualify).
AvaSettle fires webhooks to each client's configured webhookUrl for lifecycle events. Payloads are signed with the client's webhookSecret: x-avasettle-signature: sha256=<hmac-hex>.
Verify in your receiver:
const sig = createHmac('sha256', secret).update(rawBody).digest('hex');
const valid = sig === req.headers['x-avasettle-signature'].slice(7);| Event | Fires when |
|---|---|
payin.detected |
First transfer seen at deposit address (not yet confirmed) |
payin.confirmed |
Pay-in reaches required confirmations (or manually accepted) |
payout.confirmed |
Payout transaction confirmed on-chain |
Delivery is durable: events are written to a PostgreSQL outbox (avasettle_webhook_outbox) in the request path and drained by a background dispatcher (every webhook.dispatchIntervalSeconds, default 5s). Failed attempts are retried with backoff (1s → 5s → 30s) and survive process restarts; multiple instances can dispatch concurrently (FOR UPDATE SKIP LOCKED). Terminal outcomes are logged to avasettle_webhook_deliveries and queryable via GET /v1/reports/webhook-deliveries.
Set autoSweep: true in config (or AVASETTLE_AUTO_SWEEP=true) to automatically top up and sweep derived-address pay-ins when they confirm. Fires in the background — never blocks reconciliation. PaymentRouter pay-ins are excluded (they settle directly to treasury).
Never deploy straight to mainnet. The promotion path is:
docker compose up --buildbash scripts/smoke.sh— liveness, readiness, client registration, auth, scoping.- With Fuji secrets in
.env: run the demo flows (pnpm demo:payin:create,pnpm demo:payin:reconcile,pnpm demo:reports) againsthttp://localhost:3001.
- Fund a treasury wallet with Fuji AVAX (faucet) and test USDC.
- Deploy the PaymentRouter to Fuji:
Set
forge script contracts/script/Deploy.s.sol \ --rpc-url https://api.avax-test.network/ext/bc/C/rpc --broadcast
AVASETTLE_PAYMENT_ROUTER_ADDRESSto the deployed address. - Deploy the API to cloud (Cloud Run section below) with
AVASETTLE_NETWORK=avalanche-fuji. - Run
scripts/smoke.shagainst the public URL, then exercise a full real pay-in: create → pay USDC from an external wallet → reconcile → sweep → webhook received. - Let auto-reconcile + webhook dispatcher run for a few days; watch
GET /v1/reports/webhook-deliveriesandGET /v1/reports/sweep-queue(admin).
-
AVASETTLE_NETWORK=avalanche-mainnet -
AVASETTLE_USDC_ADDRESS=0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E(mainnet USDC — verify on Circle's site) - PaymentRouter deployed to mainnet and verified on Snowtrace
- Fresh treasury key + fresh mnemonic (never reuse testnet secrets)
-
AVASETTLE_MAX_PAYOUT_USDCset to a conservative per-payout cap -
AVASETTLE_MIN_CONFIRMATIONS≥ 2,AVASETTLE_TRUST_PROXYset for your LB -
AVASETTLE_DATABASE_SSL=trueagainst managed Postgres - Treasury funded with only the working-capital AVAX/USDC you can afford on a hot wallet
- Alerts on
/health/readinessand on failed webhook deliveries
# Store secrets
printf '%s' "$AVASETTLE_ADMIN_API_KEY" | gcloud secrets create avasettle-admin-api-key --data-file=-
printf '%s' "$AVASETTLE_TREASURY_PRIVATE_KEY" | gcloud secrets create avasettle-treasury-private-key --data-file=-
printf '%s' "$AVASETTLE_PAYIN_MNEMONIC" | gcloud secrets create avasettle-payin-mnemonic --data-file=-
printf '%s' "$AVASETTLE_DATABASE_URL" | gcloud secrets create avasettle-database-url --data-file=-
# Deploy
gcloud run deploy avasettle \
--source . \
--region us-central1 \
--allow-unauthenticated \
--min-instances 1 \
--set-secrets \
AVASETTLE_ADMIN_API_KEY=avasettle-admin-api-key:latest,\
AVASETTLE_TREASURY_PRIVATE_KEY=avasettle-treasury-private-key:latest,\
AVASETTLE_PAYIN_MNEMONIC=avasettle-payin-mnemonic:latest,\
AVASETTLE_DATABASE_URL=avasettle-database-url:latestCloud Run injects PORT automatically. Do not hardcode it.
See docs/api.md for the full endpoint reference with request/response examples.
Swagger UI (when server is running): http://localhost:3001/docs
- Treasury hot wallet must hold AVAX for payout gas and for topping up derived pay-in addresses before sweeping.
AVASETTLE_PAYIN_MNEMONICcontrols real EVM addresses. Treat it like a production private key.- Set explicit token contract addresses per network — never rely on defaults.
- Use
pnpm db:migratefor production migrations, notautoMigrate. - Rotate client keys with
POST /v1/admin/clients/:id/rotate-key; disable a client withPATCH /v1/admin/clients/:id {"status": "disabled"}. GET /health/readinessreports database reachability, treasury key, mnemonic, token addresses, RPC, and registered client count.
MIT — see LICENSE.