Skip to content

feat(wallets): Auto-generate Stellar wallet on user registration #7

Description

@DiegoERS

Context

Every user gets a Stellar keypair generated server-side immediately after registration. The secret key is encrypted with AES-256-GCM before storage. This module is called fire-and-forget from AuthService.register() and must never block the registration response.

📄 Reference docs:

  • backend/docs/standards/blockchain.md — keypair generation, AES-256-GCM encryption, testnet funding
  • backend/docs/standards/security.md — encryption at rest standard

Objective

Implement WalletsModule with WalletsService that:

  1. Generates a Stellar keypair
  2. Encrypts the secret key with AES-256-GCM
  3. Saves to the wallets table
  4. (Testnet only) Funds the account via Friendbot

What to implement

Encryption utilities — src/modules/wallets/wallets.crypto.ts

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const ALGORITHM = 'aes-256-gcm';

export function encryptSecretKey(secretKey: string, keyHex: string): string {
  const key       = Buffer.from(keyHex, 'hex');  // 32 bytes
  const iv        = randomBytes(16);
  const cipher    = createCipheriv(ALGORITHM, key, iv);
  const encrypted = Buffer.concat([cipher.update(secretKey, 'utf8'), cipher.final()]);
  const authTag   = cipher.getAuthTag();
  return [iv.toString('base64'), authTag.toString('base64'), encrypted.toString('base64')].join(':');
}

export function decryptSecretKey(stored: string, keyHex: string): string {
  const [ivB64, tagB64, ctB64] = stored.split(':');
  const key       = Buffer.from(keyHex, 'hex');
  const decipher  = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, 'base64'));
  decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
  return decipher.update(Buffer.from(ctB64, 'base64')).toString('utf8') + decipher.final('utf8');
}

src/modules/wallets/wallets.service.ts

async generateAndSave(userId: string): Promise<void> {
  const keypair    = Keypair.random();
  const publicKey  = keypair.publicKey();
  const secretKey  = keypair.secret();  // starts with 'S'

  const encryptedKey = encryptSecretKey(secretKey, this.config.getOrThrow('WALLET_ENCRYPTION_KEY'));

  await this.walletsRepository.create({ userId, publicKey, encryptedSecretKey: encryptedKey });

  if (this.config.get('STELLAR_NETWORK') === 'testnet') {
    await this.fundTestnetAccount(publicKey).catch((err) =>
      this.logger.error(`Friendbot funding failed for ${publicKey}`, err),
    );
  }
}

async getDecryptedKeypair(userId: string): Promise<Keypair> {
  const wallet    = await this.walletsRepository.findByUserId(userId);
  if (!wallet) throw new AppException('WALLET_NOT_FOUND', 'No wallet found for this user', 404);
  const secretKey = decryptSecretKey(wallet.encryptedSecretKey, this.config.getOrThrow('WALLET_ENCRYPTION_KEY'));
  const keypair   = Keypair.fromSecret(secretKey);
  // Zero out the plain secret after use — it stays in memory only for the duration of this call
  return keypair;
}

Packages to install

npm install @stellar/stellar-sdk

Acceptance Criteria

  • After registration, a row exists in wallets for the new user
  • wallets.public_key is a valid Stellar public key (starts with G, 56 chars)
  • wallets.encrypted_secret_key is stored in iv:authTag:ciphertext format (not plain text)
  • Decrypting the stored key with decryptSecretKey yields the original Stellar secret key
  • On testnet, the account is funded via Friendbot after creation
  • Friendbot failure does not crash registration — error is logged and swallowed
  • generateAndSave is called fire-and-forget — registration response is not delayed

Unit Tests required

src/modules/wallets/wallets.crypto.spec.ts

  • encryptSecretKey returns a string in iv:tag:ciphertext format
  • decryptSecretKey(encryptSecretKey(secret, key), key) === secret (round-trip)
  • Different calls with the same input produce different ciphertexts (random IV)
  • Tampered ciphertext throws during decryption

src/modules/wallets/wallets.service.spec.ts

  • generateAndSave: calls Keypair.random() and saves to repository
  • generateAndSave: stores encrypted (not plain) secret key
  • generateAndSave: calls Friendbot on testnet
  • generateAndSave: does NOT call Friendbot on mainnet
  • generateAndSave: does not throw if Friendbot fails
  • getDecryptedKeypair: throws WALLET_NOT_FOUND if no wallet exists

Complexity: High

Depends on: setup issue, migrations issue, register/login issue

Metadata

Metadata

Assignees

Labels

Beta-CampaignCampaign: Beta-CampaignbackendBackend (NestJS API) related taskblockchainStellar network or Trustless Work relatedcomplexity: highEstimated 1-2 weeks of work

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions