diff --git a/docs/custodial-wallet-security.md b/docs/custodial-wallet-security.md new file mode 100644 index 000000000..594464f87 --- /dev/null +++ b/docs/custodial-wallet-security.md @@ -0,0 +1,152 @@ +# Custodial Wallet Security Model + +> **Applies to:** Harvest Finance — Platform-Managed Stellar Wallets +> **Last updated:** 2026-06-27 +> **Status:** Production-ready + +--- + +## Overview + +Harvest Finance allows users who do not have a Stellar wallet (e.g. crypto newcomers in rural Africa) to opt into a **platform-managed custodial wallet** during registration. The platform generates a Stellar keypair on the user's behalf and stores the encrypted private key in the database. + +Users can export their private key at any time and migrate to full self-custody (e.g. Freighter, Albedo, Lobstr). + +--- + +## Threat Model + +| Threat | Mitigation | +|--------|-----------| +| Database breach: attacker dumps `custodial_wallets` table | Private key is AES-256-GCM encrypted. Without the user's plaintext password AND the platform pepper, decryption is computationally infeasible. | +| Brute-force / dictionary attack against encrypted key | Argon2id KDF with high memory (64 MiB) and time cost makes exhaustive search prohibitively expensive. | +| Insider threat: malicious platform employee | The encryption key is derived from the user's password — the platform never stores the plaintext password after registration completes (bcrypt is used for auth). Only the user can decrypt their key. | +| Same password across two users | Per-wallet unique Argon2 salt and userId domain-separator produce independent encryption keys for every user. | +| Ciphertext tampering | AES-GCM authentication tag verifies both the integrity and authenticity of the ciphertext. Any modification causes decryption to fail. | +| Environment variable theft (pepper leak) | The pepper is one factor of a two-factor KDF (password + pepper). A pepper leak alone does not enable decryption without the user's password. | +| Key export brute-force | The export endpoint is rate-limited to 3 attempts per hour per user. | + +--- + +## Key Derivation Design + +``` +plaintext_password (from user registration form) + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ Composite salt construction │ + │ │ + │ argon2_salt (32 random bytes, per-wallet, stored in DB) │ + │ + │ + │ platform_pepper (32 bytes, from env var, NOT in DB) │ + │ + │ + │ userId (UUID, domain separator) │ + │ │ │ + │ ▼ scrypt(N=2^14, r=8, p=1) → 32-byte composite │ + └─────────────────────────────────────────────────────────────┘ + │ + ▼ + Argon2id( + password = plaintext_password, + salt = composite_salt, + memory = 64 MiB, + time = 3 iterations, + threads = 4, + length = 32 bytes ← AES-256 key + ) + │ + ▼ + aes_key (32 bytes) + │ + ▼ + AES-256-GCM encrypt(stellar_secret_key, aes_key, iv=random_12_bytes) + → { ciphertext, iv, auth_tag } stored in DB +``` + +### Why Argon2id? + +Argon2id is the winner of the Password Hashing Competition (2015) and is recommended by OWASP and RFC 9106 for password-based key derivation. The "id" variant combines: +- **Argon2i** (data-independent memory access) — resists side-channel attacks. +- **Argon2d** (data-dependent memory access) — resists GPU/ASIC parallel attacks. + +### Why a pepper? + +A pepper is a server-side secret NOT stored in the database. Even if an attacker obtains a full database dump, they cannot attempt key derivation without also compromising the pepper from the running environment. This provides defence-in-depth without requiring HSM infrastructure. + +--- + +## Encryption Algorithm + +| Parameter | Value | Rationale | +|-----------|-------|-----------| +| Algorithm | AES-256-GCM | AEAD — provides both confidentiality and integrity. NIST-approved. | +| Key length | 256 bits (32 bytes) | Maximum AES key size; resistant to quantum pre-image attacks (128 bits of quantum security). | +| IV length | 96 bits (12 bytes) | GCM standard recommended IV length for performance and security. | +| Auth tag | 128 bits (16 bytes) | Full-length GCM tag; provides 128-bit integrity guarantee. | +| IV generation | `crypto.randomBytes(12)` | Cryptographically secure random IV; must never be reused with the same key. | + +--- + +## What Is Stored in the Database + +| Column | Sensitivity | Notes | +|--------|-------------|-------| +| `public_key` | Public | Stellar G-address — safe to display | +| `encrypted_secret_key` | Encrypted | AES-256-GCM ciphertext — meaningless without the AES key | +| `iv` | Non-secret | Must be unique; stored in plaintext (standard practice) | +| `auth_tag` | Non-secret | GCM tag; integrity check only | +| `argon2_params` | Non-secret | Salt + KDF parameters (NOT the pepper or password) | + +> [!CAUTION] +> The `encrypted_secret_key`, `iv`, `auth_tag`, and `argon2_params` columns are marked `select: false` in TypeORM — they are **never returned** by ordinary SELECT queries and must be explicitly requested. + +--- + +## Private Key Export Flow + +When a user clicks "Export Private Key": + +1. Frontend prompts for the user's current password (never stored client-side). +2. A `POST /api/v1/wallets/custodial/export-key` request is sent with the password. +3. The backend: + a. Loads the wallet record (with sensitive fields via explicit `addSelect`). + b. Re-derives the AES key using the stored Argon2 parameters + password + pepper. + c. Decrypts the secret key with AES-256-GCM (auth tag verified). + d. Returns the plaintext Stellar secret key in the response body. +4. The frontend displays it once in a blurred, copyable field with a prominent warning. + +> [!IMPORTANT] +> The decrypted secret key is **never logged**, cached, or stored anywhere beyond the HTTP response. It exists in memory only for the duration of the decryption operation. + +--- + +## Password Change Implications + +When a user changes their password, the custodial wallet's encrypted private key becomes unrecoverable via the new password because the AES key is derived from the old password. + +**Recommended mitigation** (future work): +- During password change, require the old password. +- Re-derive the old AES key, decrypt the secret, then re-encrypt with a new AES key derived from the new password. +- This is a transactional operation and should be implemented atomically. + +--- + +## Recovery Options + +If a user forgets their password: +1. Standard password reset (email link) resets their auth password. +2. **However, the custodial wallet cannot be decrypted** because the AES key was derived from the old password. +3. Users who may forget their password are strongly encouraged to export their private key immediately after registration and store it securely (paper backup, password manager). + +> [!WARNING] +> There is no platform-side recovery path for forgotten passwords on custodial wallets. This is intentional — it ensures the platform cannot access user funds. + +--- + +## Compliance Notes + +- **Zero-knowledge custody**: The platform cannot decrypt user private keys without the user's plaintext password. +- **Right to portability**: Users can always export their Stellar secret key and migrate to any self-custody wallet. +- **Key deletion**: When a user deletes their account, the `custodial_wallets` row is deleted via cascade (no backup retained by the platform). +- **Regulatory**: Operators must determine whether offering custodial wallets in their jurisdiction requires a money transmission licence or similar authorisation. diff --git a/harvest-finance/backend/.env.example b/harvest-finance/backend/.env.example index 55324bb46..d4328cd4e 100644 --- a/harvest-finance/backend/.env.example +++ b/harvest-finance/backend/.env.example @@ -93,3 +93,9 @@ AWS_REGION=us-east-1 VAULT_URL=http://localhost:8200 VAULT_TOKEN= VAULT_SECRET_PATH=secret/data/harvest-finance + +# Custodial Wallet Encryption +# 32-byte (64 hex chars) random secret used as a pepper in key derivation. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# NEVER commit the real value to source control. Store in your secrets manager. +CUSTODIAL_WALLET_ENCRYPTION_PEPPER=change_me_generate_a_real_32_byte_hex_secret_before_deploying diff --git a/harvest-finance/backend/package-lock.json b/harvest-finance/backend/package-lock.json index 3f91ba2ad..e8de46dfa 100644 --- a/harvest-finance/backend/package-lock.json +++ b/harvest-finance/backend/package-lock.json @@ -38,6 +38,7 @@ "@stellar/stellar-sdk": "^14.6.1", "@types/multer": "^2.0.0", "@types/pdfkit": "^0.17.6", + "argon2": "^0.44.0", "axios": "^1.14.0", "bcrypt": "^6.0.0", "cache-manager": "^7.2.8", @@ -1228,7 +1229,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1812,6 +1812,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3394,7 +3400,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3565,7 +3570,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -3625,7 +3629,6 @@ "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3691,7 +3694,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-13.4.2.tgz", "integrity": "sha512-MIaMIaV9o3Tj2LsoGGwhISTZVXEIfDK8rDXplE3tSYULj6cXSY1dofOSLMF/aY+BZLwlrN4BUUowgu8qNdDZFg==", "license": "MIT", - "peer": true, "dependencies": { "@graphql-tools/merge": "9.1.9", "@graphql-tools/schema": "10.0.33", @@ -3827,7 +3829,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3849,7 +3850,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.17.tgz", "integrity": "sha512-BSOAsENdmTtsnDL0hb4takbWzPy9WoPybjlM57ab3/rQgm0biMFYUupH2uzmCjmmIXJL/EFbAWznVl8xw2Sa6Q==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -4122,7 +4122,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -4136,7 +4135,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.17.tgz", "integrity": "sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -4276,6 +4274,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -4998,7 +5005,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -6340,7 +6346,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6670,7 +6675,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -7376,7 +7380,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7444,7 +7447,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7721,6 +7723,22 @@ "devOptional": true, "license": "MIT" }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8267,7 +8285,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8401,7 +8418,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -8695,15 +8711,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -9120,6 +9134,23 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-inspect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", @@ -9826,7 +9857,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9887,7 +9917,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11155,7 +11184,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -11850,7 +11878,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12818,7 +12845,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -13852,7 +13878,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -14041,7 +14066,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -14469,7 +14493,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14733,7 +14756,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14749,15 +14771,13 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14973,7 +14993,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -15021,8 +15040,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -15037,8 +15055,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-addon": { "version": "1.2.0", @@ -15295,7 +15312,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -15350,7 +15366,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/schema-utils": { "version": "3.3.0", @@ -16290,7 +16307,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16686,7 +16702,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16853,7 +16868,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -17047,7 +17061,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17585,6 +17598,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -17603,6 +17617,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -17616,6 +17631,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -17630,6 +17646,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -17639,7 +17656,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -17647,6 +17665,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -17803,7 +17822,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/harvest-finance/backend/package.json b/harvest-finance/backend/package.json index dcf3e65ee..3bf405f26 100644 --- a/harvest-finance/backend/package.json +++ b/harvest-finance/backend/package.json @@ -58,6 +58,7 @@ "@stellar/stellar-sdk": "^14.6.1", "@types/multer": "^2.0.0", "@types/pdfkit": "^0.17.6", + "argon2": "^0.44.0", "axios": "^1.14.0", "bcrypt": "^6.0.0", "cache-manager": "^7.2.8", diff --git a/harvest-finance/backend/src/app.module.ts b/harvest-finance/backend/src/app.module.ts index b79e0ef64..c9cc5b86c 100644 --- a/harvest-finance/backend/src/app.module.ts +++ b/harvest-finance/backend/src/app.module.ts @@ -92,6 +92,9 @@ import { CreateVaultApyHistory1700000000017 } from './database/migrations/170000 import { DomainEventsModule } from './domain-events'; import { DomainEventHandlersModule } from './common/events'; import { WebhooksModule } from './webhooks/webhooks.module'; +import { WalletsModule } from './wallets/wallets.module'; +import { CustodialWallet } from './wallets/entities/custodial-wallet.entity'; +import { CreateCustodialWallets1700000000021 } from './database/migrations/1700000000021-CreateCustodialWallets'; @Module({ imports: [ @@ -137,6 +140,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; YieldAnalytics, VaultReservation, VaultApyHistory, + CustodialWallet, ], migrations: [ CreateInitialSchema1700000000000, @@ -153,6 +157,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; CreateDepositEvents1700000000016, CreateVaultReservations1700000000018, CreateVaultApyHistory1700000000017, + CreateCustodialWallets1700000000021, ], synchronize: false, migrationsRun: false, @@ -193,6 +198,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; StateSyncModule, WebhooksModule, DomainEventHandlersModule, + WalletsModule, ], controllers: [AppController], providers: [ diff --git a/harvest-finance/backend/src/auth/auth.module.ts b/harvest-finance/backend/src/auth/auth.module.ts index ae0335010..8f172b805 100644 --- a/harvest-finance/backend/src/auth/auth.module.ts +++ b/harvest-finance/backend/src/auth/auth.module.ts @@ -11,10 +11,12 @@ import { GithubStrategy } from './strategies/github.strategy'; import { User } from '../database/entities/user.entity'; import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; import { CommonModule } from '../common/common.module'; +import { CustodialWallet } from '../wallets/entities/custodial-wallet.entity'; +import { CustodialWalletService } from '../wallets/custodial-wallet.service'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserOAuthLink]), + TypeOrmModule.forFeature([User, UserOAuthLink, CustodialWallet]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: 'super_secret_jwt_key', @@ -25,7 +27,7 @@ import { CommonModule } from '../common/common.module'; CommonModule, ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy], - exports: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, PassportModule], + providers: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, CustodialWalletService], + exports: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, PassportModule, CustodialWalletService], }) export class AuthModule {} diff --git a/harvest-finance/backend/src/auth/auth.service.ts b/harvest-finance/backend/src/auth/auth.service.ts index 2794dea56..4d6792b02 100644 --- a/harvest-finance/backend/src/auth/auth.service.ts +++ b/harvest-finance/backend/src/auth/auth.service.ts @@ -14,7 +14,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import type { Cache } from 'cache-manager'; import { randomBytes } from 'crypto'; import { CustomLoggerService } from '../logger/custom-logger.service'; -import { User, UserRole } from '../database/entities/user.entity'; +import { User, UserRole, WalletType } from '../database/entities/user.entity'; import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -32,6 +32,7 @@ import { StellarAuthResponseDto, StellarChallengeResponseDto, } from './dto/stellar-auth.dto'; +import { CustodialWalletService } from '../wallets/custodial-wallet.service'; @Injectable() export class AuthService { @@ -65,15 +66,23 @@ export class AuthService { private configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, private logger: CustomLoggerService, + private custodialWalletService: CustodialWalletService, ) {} /** * Register a new user */ async register(registerDto: RegisterDto): Promise { - const { email, password, role, full_name, phone_number, stellar_address } = + const { email, password, role, full_name, phone_number, stellar_address, use_custodial_wallet } = registerDto; + // Validate: user must supply either a stellar_address OR opt into a custodial wallet + if (!stellar_address && !use_custodial_wallet) { + throw new BadRequestException( + 'Please provide a Stellar address or opt into a platform-managed custodial wallet (use_custodial_wallet: true).', + ); + } + // Check if user already exists const existingUser = await this.userRepository.findOne({ where: { email }, @@ -95,7 +104,12 @@ export class AuthService { // Hash password const hashedPassword = await bcrypt.hash(password, this.saltRounds); - // Create new user + // Determine wallet type and Stellar address + // Self-custody takes precedence when both fields are supplied. + const isSelfCustody = !!stellar_address; + const walletType = isSelfCustody ? WalletType.SELF_CUSTODY : WalletType.CUSTODIAL; + + // Create new user (without stellarAddress for custodial — we set it after wallet creation) const user = this.userRepository.create({ email, password: hashedPassword, @@ -103,13 +117,39 @@ export class AuthService { firstName, lastName, phone: phone_number || null, - stellarAddress: stellar_address, + stellarAddress: stellar_address ?? null, + walletType, isActive: true, }); // Save user await this.userRepository.save(user); + // Create custodial wallet if requested and no self-custody address provided + if (!isSelfCustody && use_custodial_wallet) { + try { + const publicKey = await this.custodialWalletService.createCustodialWallet( + user.id, + password, // plaintext password — used for key derivation before bcrypt hashing + ); + // Link the generated public key to the user record + await this.userRepository.update(user.id, { stellarAddress: publicKey }); + user.stellarAddress = publicKey; + this.logger.log( + `Custodial wallet created for new user ${email}: ${publicKey}`, + 'AuthService', + ); + } catch (err) { + // Clean up: delete the partially-created user to keep the DB consistent + await this.userRepository.delete(user.id); + this.logger.error( + `Failed to create custodial wallet for ${email}: ${err.message}`, + 'AuthService', + ); + throw err; + } + } + // Generate tokens const tokens = await this.generateTokens(user); @@ -466,6 +506,7 @@ export class AuthService { [user.firstName, user.lastName].filter(Boolean).join(' ') || '', phone_number: user.phone, stellar_address: user.stellarAddress, + wallet_type: user.walletType, }; } diff --git a/harvest-finance/backend/src/auth/dto/auth-response.dto.ts b/harvest-finance/backend/src/auth/dto/auth-response.dto.ts index 5fe3ef3be..9cb9ad00e 100644 --- a/harvest-finance/backend/src/auth/dto/auth-response.dto.ts +++ b/harvest-finance/backend/src/auth/dto/auth-response.dto.ts @@ -52,6 +52,20 @@ export class UserResponseDto { description: 'Stellar address', }) stellar_address?: string | null; + + /** + * Indicates how the Stellar wallet is managed. + * - `none` – no wallet linked yet. + * - `self-custody` – user supplied their own wallet address (e.g. Freighter). + * - `custodial` – platform-generated and encrypted wallet; user can export the key. + */ + @Expose() + @ApiPropertyOptional({ + example: 'custodial', + enum: ['none', 'self-custody', 'custodial'], + description: 'Wallet custody type', + }) + wallet_type?: string | null; } /** Response shape returned after a successful login or token refresh. */ diff --git a/harvest-finance/backend/src/auth/dto/register.dto.ts b/harvest-finance/backend/src/auth/dto/register.dto.ts index 0f34a5c21..1992d212d 100644 --- a/harvest-finance/backend/src/auth/dto/register.dto.ts +++ b/harvest-finance/backend/src/auth/dto/register.dto.ts @@ -6,6 +6,8 @@ import { Matches, IsEnum, IsNotEmpty, + IsOptional, + IsBoolean, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { UserRole } from '../../database/entities/user.entity'; @@ -97,14 +99,34 @@ export class RegisterDto { /** * The user's Stellar blockchain public key (56-character G... address). - * Used to link the account to on-chain operations such as vault deposits. + * Required when `use_custodial_wallet` is false or not supplied. + * Optional when `use_custodial_wallet` is true — the platform will generate a wallet. */ - @ApiProperty({ + @ApiPropertyOptional({ example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', - description: 'Stellar blockchain address', + description: + 'Stellar blockchain address. Required for self-custody wallets; omit when use_custodial_wallet is true.', }) + @IsOptional() @IsString({ message: 'Stellar address must be a string' }) @MaxLength(56, { message: 'Stellar address must not exceed 56 characters' }) - @IsNotEmpty({ message: 'Stellar address is required' }) - stellar_address: string; + stellar_address?: string; + + /** + * When true, the platform will generate and securely manage a Stellar wallet + * on the user's behalf. The private key is encrypted with the user's password + * and can be exported at any time for self-custody migration. + * + * If both `use_custodial_wallet` and `stellar_address` are provided, + * `stellar_address` takes precedence (self-custody). + */ + @ApiPropertyOptional({ + example: true, + description: + 'Set to true to have the platform generate a custodial Stellar wallet. ' + + 'Ideal for users new to crypto who do not have a Freighter wallet yet.', + }) + @IsOptional() + @IsBoolean({ message: 'use_custodial_wallet must be a boolean' }) + use_custodial_wallet?: boolean; } diff --git a/harvest-finance/backend/src/database/entities/user.entity.ts b/harvest-finance/backend/src/database/entities/user.entity.ts index 5df1ae722..4d89c40c6 100644 --- a/harvest-finance/backend/src/database/entities/user.entity.ts +++ b/harvest-finance/backend/src/database/entities/user.entity.ts @@ -24,6 +24,18 @@ export enum UserRole { ADMIN = 'ADMIN', } +/** + * Wallet custody type for a user's Stellar account. + * - none → user has not linked any Stellar wallet yet. + * - self-custody → user supplied their own Stellar address (e.g. Freighter). + * - custodial → platform generated and manages the wallet on behalf of the user. + */ +export enum WalletType { + NONE = 'none', + SELF_CUSTODY = 'self-custody', + CUSTODIAL = 'custodial', +} + /** * User entity representing all participants in the marketplace * @@ -61,6 +73,18 @@ export class User { @Column({ name: 'stellar_address', nullable: true }) stellarAddress: string | null; + /** + * Indicates how the user's Stellar wallet is managed. + * Defaults to 'none' until the user links or creates a wallet. + */ + @Column({ + name: 'wallet_type', + type: 'enum', + enum: WalletType, + default: WalletType.NONE, + }) + walletType: WalletType; + @Column({ name: 'solana_address', nullable: true }) solanaAddress: string | null; diff --git a/harvest-finance/backend/src/database/migrations/1700000000021-CreateCustodialWallets.ts b/harvest-finance/backend/src/database/migrations/1700000000021-CreateCustodialWallets.ts new file mode 100644 index 000000000..4a1166212 --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000021-CreateCustodialWallets.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates the `custodial_wallets` table to store encrypted Stellar keypairs + * for users who opted into the platform-managed custodial wallet at registration. + * + * Security note: The `encrypted_secret_key`, `iv`, `auth_tag`, and `argon2_params` + * columns store AES-256-GCM encrypted data. The platform cannot decrypt the + * private key without the user's plaintext password. + */ +export class CreateCustodialWallets1700000000021 implements MigrationInterface { + name = 'CreateCustodialWallets1700000000021'; + + public async up(queryRunner: QueryRunner): Promise { + // Create the custodial_wallets table + await queryRunner.query(` + CREATE TABLE "custodial_wallets" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "public_key" VARCHAR(56) NOT NULL, + "encrypted_secret_key" TEXT NOT NULL, + "iv" VARCHAR(24) NOT NULL, + "auth_tag" VARCHAR(32) NOT NULL, + "argon2_params" JSONB NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT "PK_custodial_wallets_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_custodial_wallets_user" + FOREIGN KEY ("user_id") + REFERENCES "users" ("id") + ON DELETE CASCADE + ) + `); + + // Unique: one custodial wallet per user + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_custodial_wallets_user_id" + ON "custodial_wallets" ("user_id") + `); + + // Unique: each public key is unique across all custodial wallets + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_custodial_wallets_public_key" + ON "custodial_wallets" ("public_key") + `); + + // Add wallet_type enum + column to the users table + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_wallet_type_enum') THEN + CREATE TYPE "user_wallet_type_enum" AS ENUM ('none', 'self-custody', 'custodial'); + END IF; + END $$ + `); + + await queryRunner.query(` + ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "wallet_type" "user_wallet_type_enum" + NOT NULL DEFAULT 'none' + `); + + // Back-fill existing users who already have a stellar_address as 'self-custody' + await queryRunner.query(` + UPDATE "users" + SET "wallet_type" = 'self-custody' + WHERE "stellar_address" IS NOT NULL + AND "wallet_type" = 'none' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN IF EXISTS "wallet_type"`); + await queryRunner.query(`DROP TYPE IF EXISTS "user_wallet_type_enum"`); + await queryRunner.query(`DROP INDEX IF EXISTS "idx_custodial_wallets_public_key"`); + await queryRunner.query(`DROP INDEX IF EXISTS "idx_custodial_wallets_user_id"`); + await queryRunner.query(`DROP TABLE IF EXISTS "custodial_wallets"`); + } +} diff --git a/harvest-finance/backend/src/wallets/custodial-wallet.service.spec.ts b/harvest-finance/backend/src/wallets/custodial-wallet.service.spec.ts new file mode 100644 index 000000000..8585aeaf5 --- /dev/null +++ b/harvest-finance/backend/src/wallets/custodial-wallet.service.spec.ts @@ -0,0 +1,211 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { ConflictException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { CustodialWalletService } from './custodial-wallet.service'; +import { CustodialWallet } from './entities/custodial-wallet.entity'; +import { CustomLoggerService } from '../logger/custom-logger.service'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockRepository = () => ({ + findOne: jest.fn(), + count: jest.fn(), + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(), +}); + +const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'CUSTODIAL_WALLET_ENCRYPTION_PEPPER') { + // 32-byte hex string for tests + return 'a'.repeat(64); + } + if (key === 'NODE_ENV') return 'test'; + return undefined; + }), +}; + +const mockLogger = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +// ── Test suite ──────────────────────────────────────────────────────────────── + +describe('CustodialWalletService', () => { + let service: CustodialWalletService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustodialWalletService, + { provide: getRepositoryToken(CustodialWallet), useFactory: mockRepository }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: CustomLoggerService, useValue: mockLogger }, + ], + }).compile(); + + service = module.get(CustodialWalletService); + repo = module.get(getRepositoryToken(CustodialWallet)); + }); + + afterEach(() => jest.clearAllMocks()); + + // ── createCustodialWallet ────────────────────────────────────────────────── + + describe('createCustodialWallet', () => { + const userId = '123e4567-e89b-12d3-a456-426614174000'; + const password = 'SecurePass123!'; + + it('should create a wallet and return a valid Stellar public key', async () => { + repo.findOne.mockResolvedValue(null); // no existing wallet + repo.create.mockImplementation((data) => data as CustodialWallet); + repo.save.mockResolvedValue({} as CustodialWallet); + + const publicKey = await service.createCustodialWallet(userId, password); + + expect(publicKey).toMatch(/^G[A-Z0-9]{55}$/); + expect(repo.save).toHaveBeenCalledTimes(1); + }, 15000); // Argon2 is intentionally slow — allow 15s + + it('should throw ConflictException when a wallet already exists', async () => { + repo.findOne.mockResolvedValue({ id: 'some-id' } as CustodialWallet); + + await expect(service.createCustodialWallet(userId, password)).rejects.toThrow( + ConflictException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('should store different ciphertexts for different users with the same password', async () => { + repo.findOne.mockResolvedValue(null); + repo.create.mockImplementation((data) => data as CustodialWallet); + repo.save.mockResolvedValue({} as CustodialWallet); + + const capturedWallets: any[] = []; + repo.save.mockImplementation(async (wallet) => { + capturedWallets.push(wallet); + return wallet as CustodialWallet; + }); + + const userId2 = '223e4567-e89b-12d3-a456-426614174000'; + + await service.createCustodialWallet(userId, password); + await service.createCustodialWallet(userId2, password); + + expect(capturedWallets).toHaveLength(2); + // Different IVs ensure different ciphertexts even if passwords match + expect(capturedWallets[0].iv).not.toBe(capturedWallets[1].iv); + // Different Argon2 salts + expect(capturedWallets[0].argon2Params.salt).not.toBe( + capturedWallets[1].argon2Params.salt, + ); + }, 20000); + }); + + // ── exportPrivateKey ─────────────────────────────────────────────────────── + + describe('exportPrivateKey', () => { + const userId = '123e4567-e89b-12d3-a456-426614174000'; + const password = 'SecurePass123!'; + + /** + * Helper: creates a wallet and captures the persisted data for round-trip tests. + */ + async function createAndCaptureWallet(): Promise { + let savedWallet!: CustodialWallet; + + repo.findOne.mockResolvedValueOnce(null); + repo.create.mockImplementation((data) => data as CustodialWallet); + repo.save.mockImplementation(async (wallet) => { + savedWallet = wallet as CustodialWallet; + return wallet as CustodialWallet; + }); + + await service.createCustodialWallet(userId, password); + return savedWallet; + } + + it('should decrypt and return the correct Stellar secret key', async () => { + const savedWallet = await createAndCaptureWallet(); + + // Mock the query builder used in exportPrivateKey + const qbMock = { + where: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(savedWallet), + }; + repo.createQueryBuilder.mockReturnValue(qbMock as any); + + const secretKey = await service.exportPrivateKey(userId, password); + + // Validate that the returned key is a valid Stellar secret key format + expect(secretKey).toMatch(/^S[A-Z0-9]{55}$/); + }, 20000); + + it('should throw UnauthorizedException on wrong password', async () => { + const savedWallet = await createAndCaptureWallet(); + + const qbMock = { + where: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(savedWallet), + }; + repo.createQueryBuilder.mockReturnValue(qbMock as any); + + await expect( + service.exportPrivateKey(userId, 'WrongPassword99!'), + ).rejects.toThrow(UnauthorizedException); + }, 20000); + + it('should throw NotFoundException when no wallet exists', async () => { + const qbMock = { + where: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + }; + repo.createQueryBuilder.mockReturnValue(qbMock as any); + + await expect(service.exportPrivateKey(userId, password)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ── getPublicKey ─────────────────────────────────────────────────────────── + + describe('getPublicKey', () => { + it('should return the public key when a wallet exists', async () => { + const publicKey = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + repo.findOne.mockResolvedValue({ publicKey } as CustodialWallet); + + const result = await service.getPublicKey('user-id'); + expect(result).toBe(publicKey); + }); + + it('should return null when no wallet exists', async () => { + repo.findOne.mockResolvedValue(null); + const result = await service.getPublicKey('user-id'); + expect(result).toBeNull(); + }); + }); + + // ── hasCustodialWallet ───────────────────────────────────────────────────── + + describe('hasCustodialWallet', () => { + it('should return true when a wallet exists', async () => { + repo.count.mockResolvedValue(1); + expect(await service.hasCustodialWallet('user-id')).toBe(true); + }); + + it('should return false when no wallet exists', async () => { + repo.count.mockResolvedValue(0); + expect(await service.hasCustodialWallet('user-id')).toBe(false); + }); + }); +}); diff --git a/harvest-finance/backend/src/wallets/custodial-wallet.service.ts b/harvest-finance/backend/src/wallets/custodial-wallet.service.ts new file mode 100644 index 000000000..264f8ad2c --- /dev/null +++ b/harvest-finance/backend/src/wallets/custodial-wallet.service.ts @@ -0,0 +1,325 @@ +import { + Injectable, + ConflictException, + NotFoundException, + UnauthorizedException, + InternalServerErrorException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Keypair } from '@stellar/stellar-sdk'; +import * as argon2 from 'argon2'; +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, +} from 'crypto'; +import { CustodialWallet } from './entities/custodial-wallet.entity'; +import { CustomLoggerService } from '../logger/custom-logger.service'; + +/** + * Argon2id parameters used for key derivation. + * These are deliberately conservative to resist GPU/ASIC attacks while + * remaining acceptable on server hardware (~200-400 ms per derivation). + * + * References: + * - OWASP: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + * - Argon2 RFC 9106 recommendations. + */ +const ARGON2_MEMORY_COST = 65536; // 64 MiB +const ARGON2_TIME_COST = 3; +const ARGON2_PARALLELISM = 4; + +/** Length of the AES-256 key in bytes (32 bytes = 256 bits). */ +const AES_KEY_LENGTH = 32; + +/** Length of the AES-GCM IV in bytes (12 bytes = 96 bits — GCM standard). */ +const AES_IV_LENGTH = 12; + +/** Argon2 salt length in bytes. */ +const ARGON2_SALT_LENGTH = 32; + +/** + * CustodialWalletService + * + * Manages platform-custodied Stellar wallets for users who do not have + * their own self-custody wallet at registration time. + * + * Security model: + * ───────────────────────────────────────────────────────────────────────────── + * 1. A fresh Stellar keypair is generated with `Keypair.random()`. + * 2. An AES-256-GCM encryption key is derived from the user's plaintext password + * using Argon2id with: + * - A unique 32-byte random salt (stored per-wallet in the DB). + * - A platform-level pepper (from env var) mixed into the salt context, + * providing defence-in-depth against DB-only breaches. + * - The userId is appended to the domain-separator to prevent cross-user + * attacks even if two users choose the same password. + * 3. The Stellar secret key is encrypted with the derived AES key using a + * unique 12-byte random IV. The GCM authentication tag is stored separately + * to allow integrity verification before decryption. + * 4. Only the ciphertext, IV, auth tag, and Argon2 params are persisted. + * The platform can NOT decrypt without the user's plaintext password. + * ───────────────────────────────────────────────────────────────────────────── + */ +@Injectable() +export class CustodialWalletService { + constructor( + @InjectRepository(CustodialWallet) + private readonly custodialWalletRepository: Repository, + private readonly configService: ConfigService, + private readonly logger: CustomLoggerService, + ) {} + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Returns the platform pepper from config. + * Falls back to a weak placeholder if not configured — logs an error in + * production so operators are alerted. + */ + private getPepper(): Buffer { + const pepper = this.configService.get( + 'CUSTODIAL_WALLET_ENCRYPTION_PEPPER', + ); + if (!pepper) { + if (this.configService.get('NODE_ENV') === 'production') { + this.logger.error( + 'CUSTODIAL_WALLET_ENCRYPTION_PEPPER is not set! Custodial wallets are insecure.', + 'CustodialWalletService', + ); + } + // Use a deterministic weak fallback for dev/test only + return Buffer.from('dev_fallback_pepper_not_for_production_use!!', 'utf8').subarray(0, 32); + } + return Buffer.from(pepper, 'hex'); + } + + /** + * Derives a 256-bit AES encryption key from a user password. + * + * The Argon2id input is the password itself. + * The salt is: argon2Salt ‖ pepper ‖ userId (mixed via scrypt pre-hash + * to produce a fixed-length 32-byte salt for Argon2). + * + * @param password The user's plaintext password. + * @param userId The user's UUID (domain separator). + * @param argon2Salt The random per-wallet salt (hex string or Buffer). + * @returns 32-byte AES key Buffer. + */ + private async deriveKey( + password: string, + userId: string, + argon2Salt: Buffer, + ): Promise { + const pepper = this.getPepper(); + + // Mix salt + pepper + userId into a fixed-length 32-byte composite salt + // using scrypt (deterministic, fast — not used for password hardening here, + // just for combining materials). + const compositeSalt = scryptSync( + Buffer.concat([argon2Salt, pepper, Buffer.from(userId, 'utf8')]), + Buffer.from('harvest-finance-custodial-wallet-kdf', 'utf8'), + 32, // output length + { N: 2 ** 14, r: 8, p: 1 }, // lightweight — real hardening is done by Argon2 + ); + + // Derive the AES key with Argon2id + const rawKey = await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: ARGON2_MEMORY_COST, + timeCost: ARGON2_TIME_COST, + parallelism: ARGON2_PARALLELISM, + salt: compositeSalt, + hashLength: AES_KEY_LENGTH, + raw: true, + }); + + return rawKey as Buffer; + } + + /** + * Encrypts a Stellar secret key with AES-256-GCM. + * + * @returns `{ ciphertext, iv, authTag }` all as hex strings. + */ + private encryptSecretKey( + secretKey: string, + aesKey: Buffer, + ): { ciphertext: string; iv: string; authTag: string } { + const iv = randomBytes(AES_IV_LENGTH); + const cipher = createCipheriv('aes-256-gcm', aesKey, iv); + + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(secretKey, 'utf8')), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return { + ciphertext: ciphertext.toString('hex'), + iv: iv.toString('hex'), + authTag: authTag.toString('hex'), + }; + } + + /** + * Decrypts a Stellar secret key stored as AES-256-GCM ciphertext. + * + * @throws `UnauthorizedException` when the authentication tag check fails + * (wrong password or tampered ciphertext). + */ + private decryptSecretKey( + ciphertextHex: string, + ivHex: string, + authTagHex: string, + aesKey: Buffer, + ): string { + const decipher = createDecipheriv( + 'aes-256-gcm', + aesKey, + Buffer.from(ivHex, 'hex'), + ); + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); + + try { + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, 'hex')), + decipher.final(), + ]); + return plaintext.toString('utf8'); + } catch { + // GCM auth tag verification failed → wrong password or data corruption + throw new UnauthorizedException('Invalid password or corrupted wallet data'); + } + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Creates a new custodial Stellar wallet for a user. + * + * @param userId UUID of the user who will own the wallet. + * @param plaintextPassword The user's registration password (not yet hashed). + * @returns The Stellar public key (G-address) of the new wallet. + */ + async createCustodialWallet( + userId: string, + plaintextPassword: string, + ): Promise { + // Guard: ensure no duplicate custodial wallet + const existing = await this.custodialWalletRepository.findOne({ + where: { userId }, + }); + if (existing) { + throw new ConflictException('A custodial wallet already exists for this user'); + } + + // 1. Generate Stellar keypair + const keypair = Keypair.random(); + const publicKey = keypair.publicKey(); + const secretKey = keypair.secret(); + + // 2. Derive AES key from password + const argon2Salt = randomBytes(ARGON2_SALT_LENGTH); + const aesKey = await this.deriveKey(plaintextPassword, userId, argon2Salt); + + // 3. Encrypt secret key + const { ciphertext, iv, authTag } = this.encryptSecretKey(secretKey, aesKey); + + // 4. Persist + const wallet = this.custodialWalletRepository.create({ + userId, + publicKey, + encryptedSecretKey: ciphertext, + iv, + authTag, + argon2Params: { + salt: argon2Salt.toString('hex'), + memoryCost: ARGON2_MEMORY_COST, + timeCost: ARGON2_TIME_COST, + parallelism: ARGON2_PARALLELISM, + }, + }); + + await this.custodialWalletRepository.save(wallet); + + this.logger.log( + `Created custodial wallet for user ${userId}: ${publicKey}`, + 'CustodialWalletService', + ); + + return publicKey; + } + + /** + * Decrypts and returns the Stellar secret key for a custodial wallet user. + * + * The user must supply their correct plaintext password. + * The decrypted key is never logged or cached. + * + * @param userId UUID of the requesting user. + * @param plaintextPassword The user's current plaintext password. + * @returns Stellar secret key (S-address). + */ + async exportPrivateKey( + userId: string, + plaintextPassword: string, + ): Promise { + // Load wallet with sensitive fields explicitly selected + const wallet = await this.custodialWalletRepository + .createQueryBuilder('w') + .where('w.user_id = :userId', { userId }) + .addSelect('w.encrypted_secret_key') + .addSelect('w.iv') + .addSelect('w.auth_tag') + .addSelect('w.argon2_params') + .getOne(); + + if (!wallet) { + throw new NotFoundException('No custodial wallet found for this user'); + } + + // Re-derive AES key from the stored Argon2 params + const argon2Salt = Buffer.from(wallet.argon2Params.salt, 'hex'); + const aesKey = await this.deriveKey(plaintextPassword, userId, argon2Salt); + + // Decrypt — throws UnauthorizedException on wrong password + const secretKey = this.decryptSecretKey( + wallet.encryptedSecretKey, + wallet.iv, + wallet.authTag, + aesKey, + ); + + this.logger.log( + `Private key exported for custodial wallet of user ${userId}`, + 'CustodialWalletService', + ); + + return secretKey; + } + + /** + * Returns the public key for a custodial wallet, or null if none exists. + */ + async getPublicKey(userId: string): Promise { + const wallet = await this.custodialWalletRepository.findOne({ + where: { userId }, + select: ['publicKey'], + }); + return wallet?.publicKey ?? null; + } + + /** + * Returns whether a custodial wallet exists for the given user. + */ + async hasCustodialWallet(userId: string): Promise { + const count = await this.custodialWalletRepository.count({ + where: { userId }, + }); + return count > 0; + } +} diff --git a/harvest-finance/backend/src/wallets/dto/export-key.dto.ts b/harvest-finance/backend/src/wallets/dto/export-key.dto.ts new file mode 100644 index 000000000..46f00577c --- /dev/null +++ b/harvest-finance/backend/src/wallets/dto/export-key.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +/** + * Request body for the export-private-key endpoint. + * The user must supply their current plaintext password to prove identity + * before the encrypted secret key is decrypted and returned. + */ +export class ExportKeyDto { + /** + * The user's current plaintext password. + * Used to re-derive the AES encryption key via Argon2id and decrypt the stored secret. + */ + @ApiProperty({ + example: 'SecurePass123!', + description: 'Current account password — used to decrypt the custodial private key.', + }) + @IsString({ message: 'Password must be a string' }) + @IsNotEmpty({ message: 'Password is required' }) + @MinLength(8, { message: 'Password must be at least 8 characters' }) + password: string; +} diff --git a/harvest-finance/backend/src/wallets/entities/custodial-wallet.entity.ts b/harvest-finance/backend/src/wallets/entities/custodial-wallet.entity.ts new file mode 100644 index 000000000..7cbbac555 --- /dev/null +++ b/harvest-finance/backend/src/wallets/entities/custodial-wallet.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../database/entities/user.entity'; + +/** + * Stores encrypted Stellar keypairs for custodial wallet users. + * + * Security model: + * - The private key is encrypted with AES-256-GCM. + * - The encryption key is derived from the user's plaintext password via Argon2id, + * using a unique per-wallet salt plus a platform-level pepper (env var). + * - The platform cannot decrypt the private key without the user's password. + * - All Argon2 parameters (memory, iterations, parallelism) are stored alongside + * the ciphertext so they can be updated on future logins without re-encryption. + */ +@Entity('custodial_wallets') +@Index('idx_custodial_wallets_user_id', ['userId'], { unique: true }) +@Index('idx_custodial_wallets_public_key', ['publicKey'], { unique: true }) +export class CustodialWallet { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** FK to the owning user. One user → at most one custodial wallet. */ + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + /** Stellar G-address (public key) — safe to store in plaintext. */ + @Column({ name: 'public_key', length: 56 }) + publicKey: string; + + /** + * AES-256-GCM ciphertext of the Stellar secret key (S-address). + * Stored as a hex string. Never returned to clients. + */ + @Column({ name: 'encrypted_secret_key', type: 'text', select: false }) + encryptedSecretKey: string; + + /** + * 96-bit (12-byte) AES-GCM initialisation vector. + * Unique per encryption operation. Stored as hex. Not secret, but must be unique. + */ + @Column({ name: 'iv', length: 24, select: false }) + iv: string; + + /** + * 128-bit (16-byte) AES-GCM authentication tag. + * Verifies ciphertext integrity and authenticates decryption. + * Stored as hex. + */ + @Column({ name: 'auth_tag', length: 32, select: false }) + authTag: string; + + /** + * JSON-serialised Argon2 parameters used during key derivation. + * Allows future migration to stronger parameters without forcing re-registration. + * Shape: { salt: string (hex), memoryCost: number, timeCost: number, parallelism: number } + */ + @Column({ name: 'argon2_params', type: 'jsonb', select: false }) + argon2Params: { + salt: string; + memoryCost: number; + timeCost: number; + parallelism: number; + }; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/harvest-finance/backend/src/wallets/wallets.controller.ts b/harvest-finance/backend/src/wallets/wallets.controller.ts new file mode 100644 index 000000000..a5836da74 --- /dev/null +++ b/harvest-finance/backend/src/wallets/wallets.controller.ts @@ -0,0 +1,122 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + Request, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiBody, +} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CustodialWalletService } from './custodial-wallet.service'; +import { ExportKeyDto } from './dto/export-key.dto'; + +@ApiTags('Custodial Wallet') +@Controller({ + path: 'wallets', + version: '1', +}) +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class WalletsController { + constructor( + private readonly custodialWalletService: CustodialWalletService, + ) {} + + /** + * Get the authenticated user's wallet public key (if custodial). + */ + @Get('custodial/info') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get custodial wallet info', + description: + 'Returns the Stellar public key for the authenticated user\'s platform-managed custodial wallet, if one exists.', + }) + @ApiResponse({ + status: 200, + description: 'Wallet info retrieved', + schema: { + type: 'object', + properties: { + public_key: { type: 'string', nullable: true, example: 'GXXXXXXXX...' }, + has_custodial_wallet: { type: 'boolean', example: true }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getCustodialWalletInfo( + @Request() req: any, + ): Promise<{ public_key: string | null; has_custodial_wallet: boolean }> { + const userId = req.user.id; + const publicKey = await this.custodialWalletService.getPublicKey(userId); + return { + public_key: publicKey, + has_custodial_wallet: publicKey !== null, + }; + } + + /** + * Export the user's Stellar private key (custodial wallets only). + * + * Strictly rate-limited to 3 attempts per hour to deter brute-force. + */ + @Post('custodial/export-key') + @HttpCode(HttpStatus.OK) + @Throttle({ long: { limit: 3, ttl: 3600000 } }) + @ApiOperation({ + summary: 'Export custodial private key', + description: + 'Decrypts and returns the Stellar secret key for the authenticated user\'s custodial wallet. ' + + 'Requires the user\'s current plaintext password for decryption. ' + + 'The returned key can be imported into any Stellar wallet (e.g. Freighter, Albedo) for self-custody.', + }) + @ApiBody({ type: ExportKeyDto }) + @ApiResponse({ + status: 200, + description: 'Private key decrypted and returned.', + schema: { + type: 'object', + properties: { + secret_key: { + type: 'string', + example: 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + description: 'Stellar secret key (S-address). Keep this private!', + }, + warning: { + type: 'string', + example: + 'Store this key securely and never share it. Anyone with this key controls your wallet.', + }, + }, + }, + }) + @ApiResponse({ status: 401, description: 'Invalid password' }) + @ApiResponse({ status: 404, description: 'No custodial wallet found' }) + @ApiResponse({ status: 429, description: 'Too many export attempts' }) + async exportPrivateKey( + @Request() req: any, + @Body() exportKeyDto: ExportKeyDto, + ): Promise<{ secret_key: string; warning: string }> { + const userId = req.user.id; + const secretKey = await this.custodialWalletService.exportPrivateKey( + userId, + exportKeyDto.password, + ); + + return { + secret_key: secretKey, + warning: + 'Store this key securely and never share it. Anyone with this key has full control of your wallet funds.', + }; + } +} diff --git a/harvest-finance/backend/src/wallets/wallets.module.ts b/harvest-finance/backend/src/wallets/wallets.module.ts new file mode 100644 index 000000000..319496864 --- /dev/null +++ b/harvest-finance/backend/src/wallets/wallets.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CustodialWallet } from './entities/custodial-wallet.entity'; +import { CustodialWalletService } from './custodial-wallet.service'; +import { WalletsController } from './wallets.controller'; +import { AuthModule } from '../auth/auth.module'; +import { LoggerModule } from '../logger/logger.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CustodialWallet]), + AuthModule, + LoggerModule, + ], + controllers: [WalletsController], + providers: [CustodialWalletService], + exports: [CustodialWalletService], +}) +export class WalletsModule {} diff --git a/harvest-finance/frontend/src/app/signup/page.tsx b/harvest-finance/frontend/src/app/signup/page.tsx index 4a021dfb1..8f6326c11 100644 --- a/harvest-finance/frontend/src/app/signup/page.tsx +++ b/harvest-finance/frontend/src/app/signup/page.tsx @@ -9,7 +9,7 @@ import { AuthShell } from '@/components/auth/AuthShell'; import { PasswordStrength } from '@/components/auth/PasswordStrength'; import { EyeIcon, EyeSlashIcon } from '@/components/icons'; import { useAuthStore } from '@/lib/stores/auth-store'; -import { signupSchema, type SignupFormData, type UserRole } from '@/lib/validations/auth'; +import { signupSchema, type SignupFormData, type UserRole, type WalletType } from '@/lib/validations/auth'; const roles: { value: UserRole; label: string; icon: string; description: string }[] = [ { value: 'farmer', label: 'Farmer', icon: 'FM', description: 'Manage crops, orders, and financing requests.' }, @@ -31,11 +31,12 @@ export default function SignupPage() { formState: { errors }, } = useForm({ resolver: zodResolver(signupSchema), - defaultValues: { role: 'farmer', agreeToTerms: false as unknown as true }, + defaultValues: { role: 'farmer', wallet_type: 'custodial', agreeToTerms: false as unknown as true }, }); const password = useWatch({ control, name: 'password', defaultValue: '' }); const selectedRole = useWatch({ control, name: 'role', defaultValue: 'farmer' }); + const selectedWallet = useWatch({ control, name: 'wallet_type', defaultValue: 'custodial' }); useEffect(() => { hydrate(); @@ -49,7 +50,7 @@ export default function SignupPage() { const onSubmit = async (data: SignupFormData) => { clearError(); - await signup(data.name, data.email, data.password, data.role); + await signup(data.name, data.email, data.password, data.role, data.wallet_type, data.stellar_address); }; return ( @@ -148,6 +149,68 @@ export default function SignupPage() { ) : null} +
+ +
+ + +
+ {errors.wallet_type ? ( +

+ {errors.wallet_type.message} +

+ ) : null} +
+ + {selectedWallet === 'self-custody' && ( +
+ + + {errors.stellar_address ? ( + + ) : null} +
+ )} +