Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,19 @@ SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD=50

# Authentication
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_COOKIE_NAME=remitlend_jwt
INTERNAL_API_KEY=change-me

# RBAC — comma-separated Stellar public keys (G...)
# Wallets listed in ADMIN_WALLETS receive the admin role (admin:all scope).
# Wallets listed in LENDER_WALLETS receive the lender role.
# Any unlisted wallet defaults to borrower.
ADMIN_WALLETS=
LENDER_WALLETS=

# Debugging (never enable in production)
EXPOSE_STACK_TRACES=false

# Webhooks
WEBHOOK_REQUEST_TIMEOUT_MS=30000

Expand Down
99 changes: 89 additions & 10 deletions contracts/loan_manager/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,22 @@ use lending_pool::{LendingPool, LendingPoolClient};
use remittance_nft::{RemittanceNFT, RemittanceNFTClient};
use soroban_sdk::testutils::{Events, Ledger as _};
use soroban_sdk::token::{Client as TokenClient, StellarAssetClient};
use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, FromVal, String};
use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, BytesN, Env, FromVal, String};

// Mock RateOracle contract for testing the oracle interest-rate code path.
#[contract]
pub struct MockRateOracle;

#[contractimpl]
impl MockRateOracle {
pub fn get_rate(_env: Env, _borrower: Address, _amount: i128, _score: u32) -> u32 {
_env.storage().instance().get(&"rate").unwrap_or(1200)
}

pub fn set_rate(env: Env, rate: u32) {
env.storage().instance().set(&"rate", &rate);
}
}

fn setup_test<'a>(
env: &Env,
Expand Down Expand Up @@ -2291,12 +2306,20 @@ fn test_oracle_rate_within_bounds_accepted() {
let stellar_token = StellarAssetClient::new(&env, &token_id);
stellar_token.mint(&pool_client, &10_000);

// Request loan - should use default rate since no oracle is set
// Deploy mock oracle returning 800 BPS (within default bounds 1..100_000)
let oracle_id = env.register(MockRateOracle, ());
let oracle_client = MockRateOracleClient::new(&env, &oracle_id);
oracle_client.set_rate(&800);

// Set the oracle on the loan manager
manager.set_rate_oracle(&oracle_id);

// Request loan — the oracle branch should be taken
let loan_id = manager.request_loan(&borrower, &1000, &17280);
let loan = manager.get_loan(&loan_id);

// Default rate should be 1200 BPS (12%)
assert_eq!(loan.interest_rate_bps, 1200);
// Should use the oracle rate (800 BPS), not the default (1200 BPS)
assert_eq!(loan.interest_rate_bps, 800);
}

#[test]
Expand Down Expand Up @@ -2456,14 +2479,19 @@ fn test_oracle_rate_below_min_falls_back_to_default() {
let stellar_token = StellarAssetClient::new(&env, &token_id);
stellar_token.mint(&pool_client, &10_000);

// Set min rate to 500 BPS
// Deploy mock oracle returning 100 BPS (below the min we will set)
let oracle_id = env.register(MockRateOracle, ());
let oracle_client = MockRateOracleClient::new(&env, &oracle_id);
oracle_client.set_rate(&100);

manager.set_rate_oracle(&oracle_id);
manager.set_min_rate_bps(&500);

// Request loan - should use default rate (1200) since no oracle is set
// Request loan — oracle returns 100 which is below min_rate_bps=500
let loan_id = manager.request_loan(&borrower, &1000, &17280);
let loan = manager.get_loan(&loan_id);

// Should use default rate (1200 BPS) which is within bounds
// Should fall back to default rate (1200 BPS)
assert_eq!(loan.interest_rate_bps, 1200);
}

Expand All @@ -2487,14 +2515,19 @@ fn test_oracle_rate_above_max_falls_back_to_default() {
let stellar_token = StellarAssetClient::new(&env, &token_id);
stellar_token.mint(&pool_client, &10_000);

// Set max rate to 2000 BPS (20%)
// Deploy mock oracle returning 5000 BPS (above the max we will set)
let oracle_id = env.register(MockRateOracle, ());
let oracle_client = MockRateOracleClient::new(&env, &oracle_id);
oracle_client.set_rate(&5000);

manager.set_rate_oracle(&oracle_id);
manager.set_max_rate_bps(&2_000);

// Request loan - should use default rate (1200) since no oracle is set
// Request loan — oracle returns 5000 which is above max_rate_bps=2000
let loan_id = manager.request_loan(&borrower, &1000, &17280);
let loan = manager.get_loan(&loan_id);

// Should use default rate (1200 BPS) which is within bounds
// Should fall back to default rate (1200 BPS)
assert_eq!(loan.interest_rate_bps, 1200);
}

Expand Down Expand Up @@ -3588,3 +3621,49 @@ fn test_get_loan_health_matches_liquidation_state() {
assert_eq!(pending_debt, 0);
assert_eq!(pending_ratio, 0);
}

// ── Oracle get/set and event tests ──────────────────────────────────────────

#[test]
fn test_get_rate_oracle_returns_set_address() {
let env = Env::default();
env.mock_all_auths_allowing_non_root_auth();

let (manager, _nft_client, _pool_client, _token_id, _admin) = setup_test(&env);

// Initially no oracle is set
assert_eq!(manager.get_rate_oracle(), None);

// Deploy and set a mock oracle
let oracle_id = env.register(MockRateOracle, ());
manager.set_rate_oracle(&oracle_id);

// get_rate_oracle should return the address we just set
assert_eq!(manager.get_rate_oracle(), Some(oracle_id));
}

#[test]
fn test_set_rate_oracle_emits_rate_oracle_updated_event() {
let env = Env::default();
env.mock_all_auths_allowing_non_root_auth();

let (manager, _nft_client, _pool_client, _token_id, _admin) = setup_test(&env);

let oracle_id = env.register(MockRateOracle, ());
manager.set_rate_oracle(&oracle_id);

let events = env.events().all();
let has_oracle_event = events.iter().any(|(_contract_id, topics, _data)| {
topics.len() == 1
&& topics
.get(0)
.map(|t| {
t == soroban_sdk::Val::from_val(
&env,
&soroban_sdk::Symbol::new(&env, "RateOracleUpdated"),
)
})
.unwrap_or(false)
});
assert!(has_oracle_event, "RateOracleUpdated event should be emitted");
}
4 changes: 4 additions & 0 deletions docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ This document lists every environment variable used by the RemitLend platform. E
| `SCORE_RECONCILIATION_AUTOCORRECT_THRESHOLD` | ✓ | ✓ | ✓ | `50` | Max points auto-corrected per run | `backend/src/config/scores.ts` |
| `JWT_SECRET` | ✓ | ✓ | ✓ | `your-super-secret-jwt-key-change-in-production` | JWT signing/verification secret | `backend/src/middleware/jwtAuth.ts` |
| `INTERNAL_API_KEY` | ✓ | ✓ | ✓ | `change-me` | API key for internal endpoints | `backend/src/middleware/auth.ts` |
| `ADMIN_WALLETS` | ✓ | ✓ | ✓ | — | Comma-separated Stellar public keys granted the `admin` role (`admin:all` scope). **Security-critical**: any wallet listed here receives full admin privileges. Unlisted wallets default to `borrower`. | `backend/src/auth/rbac.ts` |
| `LENDER_WALLETS` | ✓ | ✓ | ✓ | — | Comma-separated Stellar public keys granted the `lender` role (`read:loans`, `read:pool` scopes). Unlisted wallets default to `borrower`. | `backend/src/auth/rbac.ts` |
| `EXPOSE_STACK_TRACES` | — | — | — | `false` | When `"true"`, include stack traces in error responses. **Never enable in production.** | `backend/src/middleware/errorHandler.ts` |
| `JWT_COOKIE_NAME` | ✓ | ✓ | ✓ | `remitlend_jwt` | Name of the HTTP cookie used to transport the JWT token | `backend/src/middleware/jwtAuth.ts` |
| `WEBHOOK_REQUEST_TIMEOUT_MS` | ✓ | ✓ | ✓ | `30000` | Outgoing webhook request timeout | `backend/src/services/webhookService.ts` |
| `SENTRY_DSN` | — | ✓ | ✓ | — | Sentry DSN for backend error tracking | `backend/src/app.ts` |
| `NOTIFICATION_RETENTION_DAYS` | ✓ | ✓ | ✓ | `90` | Days to keep unread notifications | `backend/src/services/notificationService.ts` |
Expand Down
42 changes: 21 additions & 21 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,39 +232,34 @@ While deactivated:
## Verifying HMAC Signatures

Each delivery includes an `X-RemitLend-Signature` header containing an
HMAC-SHA256 signature of the raw request body.
HMAC-SHA256 signature of the **raw request body**.

**Header format:**
```
X-RemitLend-Signature: t=1745827200,v1=abc123def456...
X-RemitLend-Signature: sha256=<hex-encoded-hmac>
```

- `t` — Unix timestamp of when the signature was generated
- `v1` — Hexadecimal HMAC-SHA256 digest
The value is `sha256=` followed by the lowercase hex-encoded HMAC-SHA256
digest computed over the raw request body (no timestamp prefix).

### Verification snippet (Node.js)

```typescript
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhookSignature(
body: string,
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const parts = signatureHeader.split(",");
const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
const digest = parts.find((p) => p.startsWith("v1="))?.slice(3);

if (!timestamp || !digest) return false;

const payload = `${timestamp}.${body}`;
const expected = createHmac("sha256", secret)
.update(payload)
.digest("hex");

if (expected.length !== digest.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(digest));
const expected =
"sha256=" +
createHmac("sha256", secret).update(rawBody).digest("hex");

const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
```

Expand All @@ -274,9 +269,14 @@ function verifyWebhookSignature(

### Obtaining your secret

The signing secret is the `WEBHOOK_SIGNING_SECRET` environment variable
configured on the RemitLend server. Contact the RemitLend team to obtain
your shared secret.
The signing secret is the **per-subscription secret** returned in the
response when you register the webhook subscription (see
[Creating a Subscription](#creating-a-subscription)). It is **not** a
global environment variable. Store it securely on your server and use it
to verify each incoming delivery.

See also: [docs/wiki/webhook-signatures.md](wiki/webhook-signatures.md)
for additional language examples.

---

Expand Down
5 changes: 3 additions & 2 deletions docs/wiki/webhook-signatures.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ function verifySignature(secret, rawBody, signatureHeader) {
return crypto.timingSafeEqual(a, b);
}

// Express example
// Express example — YOUR_SUBSCRIPTION_SECRET is the per-subscription
// secret returned when you registered the webhook, not an env var.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-remitlend-signature"];
if (!verifySignature(process.env.WEBHOOK_SECRET, req.body, sig)) {
if (!verifySignature(YOUR_SUBSCRIPTION_SECRET, req.body, sig)) {
return res.status(401).send("Invalid signature");
}
// process req.body …
Expand Down
14 changes: 12 additions & 2 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,21 @@ WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json

# Install production-only dependencies
RUN npm ci --omit=dev && rm -f package-lock.json

# Non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["npm", "run", "start"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["npm", "run", "start"]
Loading