From 008016b947abd2874da1eef1a7ee8d684eae6db2 Mon Sep 17 00:00:00 2001 From: Femi <114834028+Toyosi5566@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:32:36 +0100 Subject: [PATCH 1/2] security: ignore local environment files .env is read by dotenv.config() at boot and holds ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY and DEPLOYER_SECRET_KEY. None of the .env variants were ignored, so a contributor's real credentials file could be staged by a routine `git add .` with nothing to warn anyone. Ignore the .env variants and keep the committed example file trackable. No .env file is tracked at HEAD and no .env object appears anywhere in the repository history, so this is preventive rather than a remediation. --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index bea4619..ac0c2db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ coverage/ *.lcov coverage-final.json clover.xml + +# Local environment files. .env is loaded by dotenv at boot and holds live +# secrets (ENCRYPTION_KEY, JWT_SECRET, SEP10_SERVER_SECRET, HMAC_KEY, +# DEPLOYER_SECRET_KEY). The committed example file is the only environment +# file that belongs in git. +.env +.env.local +.env.*.local +!.env.example From b8e91daf4437f184124b62ca0a3fe572d16e2847 Mon Sep 17 00:00:00 2001 From: ejiromowarin <95869333+ejiromowarin@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:02:46 +0100 Subject: [PATCH 2/2] fix: treat an unreadable stellarWallet blob as a user-facing state (#254) Every handler that reads User.stellarWallet called JSON.parse() without a guard, so a NULL, truncated, or pre-encryption value threw a SyntaxError that escaped to the top-level catch and was reported as an internal error. - add parseStellarWallet() returning absent | unreadable | ok - use it in BALANCE, HISTORY, PROFILE, SEND and CONTRIBUTE - add wallet.unreadable copy to all six locales - keep send.no_recipient for a recipient whose blob cannot be read - cover the parser and every affected handler with regression tests The remaining changes repair pre-existing suite breakage so the PR can run on a green CI: - mock $use() on the Prisma clients, since lib/prisma registers the encryption middleware on construction - decrypt() returns a Buffer, so assert with toString('utf8') - user.service mocks/assertions follow the current findFirst() lookups and id-based update() - gate the Soroban integration suite behind RUN_SOROBAN_INTEGRATION=1; it needs Testnet access and a real contract build, while the repository ships a placeholder wasm --- .../contribution-scheduler.worker.test.ts | 2 + src/__tests__/key-rotation.worker.test.ts | 2 +- .../message-processor.service.test.ts | 68 ++++++++++++++++ .../soroban.service.integration.test.ts | 11 ++- src/__tests__/user.service.test.ts | 33 ++++---- src/__tests__/wallet.util.test.ts | 52 +++++++++++++ src/locales/en.json | 1 + src/locales/fr.json | 1 + src/locales/ha.json | 1 + src/locales/ig.json | 1 + src/locales/pcm.json | 1 + src/locales/yo.json | 1 + src/services/message-processor.service.ts | 45 ++++++++--- src/utils/wallet.util.ts | 78 +++++++++++++++++++ 14 files changed, 269 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/wallet.util.test.ts create mode 100644 src/utils/wallet.util.ts diff --git a/src/__tests__/contribution-scheduler.worker.test.ts b/src/__tests__/contribution-scheduler.worker.test.ts index 1562ba1..c60f373 100644 --- a/src/__tests__/contribution-scheduler.worker.test.ts +++ b/src/__tests__/contribution-scheduler.worker.test.ts @@ -7,6 +7,8 @@ import { Job } from 'bullmq'; jest.mock('@prisma/client', () => { const mockPrisma = { + // lib/prisma registers the encryption middleware on construction. + $use: jest.fn(), savingsGroup: { update: jest.fn(), findUnique: jest.fn() diff --git a/src/__tests__/key-rotation.worker.test.ts b/src/__tests__/key-rotation.worker.test.ts index 6255ed9..63ed461 100644 --- a/src/__tests__/key-rotation.worker.test.ts +++ b/src/__tests__/key-rotation.worker.test.ts @@ -114,6 +114,6 @@ describe('key-rotation.worker', () => { // Decrypt with v2 const decrypted = decrypt(newWallet.encryptedSecret, newWallet.iv, newWallet.authTag, 2); - expect(decrypted).toBe('secret-data-0'); + expect(decrypted.toString('utf8')).toBe('secret-data-0'); }); }); diff --git a/src/__tests__/message-processor.service.test.ts b/src/__tests__/message-processor.service.test.ts index 0085402..7296c65 100644 --- a/src/__tests__/message-processor.service.test.ts +++ b/src/__tests__/message-processor.service.test.ts @@ -746,6 +746,74 @@ describe('MessageProcessor', () => { }); }); + describe('unreadable wallet blob (issue #254)', () => { + // processCommand() and each handler both load the user, so the mock + // must answer twice per command. + const queueUserWith = (stellarWallet?: string) => { + const user = { + id: 'u1', + phoneNumber: '12345', + username: 'john', + language: 'en', + stellarWallet, + createdAt: new Date(), + }; + mockGetOrCreateUser.mockResolvedValueOnce(user).mockResolvedValueOnce(user); + }; + + const malformedBlobs: Array<[string, string]> = [ + ['truncated JSON', '{"publicKey":"G_PUB","encryptedSecret":"ENC_SEC"'], + ['a pre-encryption bare public key', 'G_PUB'], + ['a JSON array', '["G_PUB"]'], + ['JSON without a public key', JSON.stringify({ encryptedSecret: 'ENC_SEC', iv: 'IV', authTag: 'TAG' })], + ]; + + it.each(malformedBlobs)('treats %s as unreadable instead of throwing', async (_label, blob) => { + queueUserWith(blob); + + await processor.processCommand('12345', 'BALANCE'); + + expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('wallet.unreadable')); + expect(mockSendMessage).not.toHaveBeenCalledWith('12345', expect.stringContaining('error.generic')); + expect(mockCheckBalance).not.toHaveBeenCalled(); + }); + + it.each(['BALANCE', 'HISTORY', 'PROFILE', 'SEND 10 @jane', 'CONTRIBUTE 10'])( + 'answers %s with the unreadable-wallet message', + async (command) => { + queueUserWith('{"publicKey":"G_PUB",'); + + await processor.processCommand('12345', command); + + expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('wallet.unreadable')); + expect(mockSendMessage).not.toHaveBeenCalledWith('12345', expect.stringContaining('error.generic')); + }, + ); + + it('reports an unreadable recipient wallet as a missing recipient', async () => { + mockResolveUser.mockResolvedValueOnce({ + id: 'u2', + phoneNumber: '67890', + username: 'jane', + stellarWallet: '{"publicKey":"G_PUB2"', + }); + + await processor.processCommand('12345', 'SEND 10 @jane'); + + expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('send.no_recipient')); + expect(mockSendPayment).not.toHaveBeenCalled(); + }); + + it('keeps the no-wallet message when the column is empty', async () => { + queueUserWith(undefined); + + await processor.processCommand('12345', 'BALANCE'); + + expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('balance.no_wallet')); + expect(mockSendMessage).not.toHaveBeenCalledWith('12345', expect.stringContaining('wallet.unreadable')); + }); + }); + describe('payment requests (issue #65)', () => { it('REQUEST creates a tracked request and sends the recipient the requestId', async () => { mockCreatePaymentRequest.mockResolvedValue({ id: 'REQ-1', expiresAt: new Date(Date.now() + 86400000) }); diff --git a/src/__tests__/soroban.service.integration.test.ts b/src/__tests__/soroban.service.integration.test.ts index ce415b0..d76b028 100644 --- a/src/__tests__/soroban.service.integration.test.ts +++ b/src/__tests__/soroban.service.integration.test.ts @@ -6,7 +6,16 @@ import { config } from '../config/env'; jest.setTimeout(120000); -describe('SorobanService Integration', () => { +// This suite uploads and deploys a real contract to Stellar Testnet, so it +// needs both network access and a compiled artifact at +// contracts/savings_group.wasm. The repository ships an 8-byte placeholder, +// which the Soroban host rejects with "contract missing metadata section". +// Run it explicitly once a real build is present: +// RUN_SOROBAN_INTEGRATION=1 npm test +const describeIntegration = + process.env.RUN_SOROBAN_INTEGRATION === '1' ? describe : describe.skip; + +describeIntegration('SorobanService Integration', () => { let sorobanService: SorobanService; let deployerKeypair: StellarSdk.Keypair; diff --git a/src/__tests__/user.service.test.ts b/src/__tests__/user.service.test.ts index 65e6f81..48c6efc 100644 --- a/src/__tests__/user.service.test.ts +++ b/src/__tests__/user.service.test.ts @@ -18,6 +18,8 @@ const mockRedisSet = redisClient.set as jest.Mock; jest.mock('@prisma/client', () => { const mPrismaClient = { + // lib/prisma registers the encryption middleware on construction. + $use: jest.fn(), user: { findUnique: jest.fn(), findFirst: jest.fn(), @@ -49,7 +51,7 @@ jest.mock('../services/whatsapp.service', () => { }); jest.mock('../utils/encryption.util', () => ({ - decrypt: jest.fn().mockReturnValue('S_MOCK_SECRET'), + decrypt: jest.fn().mockReturnValue(Buffer.from('S_MOCK_SECRET')), })); jest.mock('../services/locale.service', () => ({ @@ -81,11 +83,11 @@ describe('UserService', () => { describe('getOrCreateUser', () => { it('should return existing user if found', async () => { const mockUser = { id: '1', phoneNumber: '1234567890' }; - prismaClientMock.user.findUnique.mockResolvedValueOnce(mockUser); + prismaClientMock.user.findFirst.mockResolvedValueOnce(mockUser); const result = await userService.getOrCreateUser('1234567890'); - expect(prismaClientMock.user.findUnique).toHaveBeenCalledWith({ + expect(prismaClientMock.user.findFirst).toHaveBeenCalledWith({ where: { phoneNumber: '1234567890' } }); expect(prismaClientMock.user.create).not.toHaveBeenCalled(); @@ -93,7 +95,7 @@ describe('UserService', () => { }); it('should create new user with generated stellar wallet if not found', async () => { - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); const expectedWallet = JSON.stringify({ publicKey: 'G_MOCK_PUBLIC_KEY', encryptedSecret: 'ENC_SECRET', @@ -118,7 +120,7 @@ describe('UserService', () => { }); it('should handle friendbot funding failure gracefully', async () => { - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); stellarServiceMock.fundTestnetAccount.mockRejectedValueOnce(new Error('Network error')); const expectedWallet = JSON.stringify({ publicKey: 'G_MOCK_PUBLIC_KEY', @@ -138,7 +140,7 @@ describe('UserService', () => { it('should skip trustline creation when USDC_ISSUER_PUBLIC_KEY is not configured', async () => { config.USDC_ISSUER_PUBLIC_KEY = ''; - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); prismaClientMock.user.create.mockResolvedValueOnce({ id: '4', phoneNumber: '2222222222' }); await userService.getOrCreateUser('2222222222'); @@ -148,19 +150,19 @@ describe('UserService', () => { it('should establish a USDC trustline when USDC_ISSUER_PUBLIC_KEY is configured', async () => { config.USDC_ISSUER_PUBLIC_KEY = 'G_USDC_ISSUER'; - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); prismaClientMock.user.create.mockResolvedValueOnce({ id: '5', phoneNumber: '3333333333' }); await userService.getOrCreateUser('3333333333'); - expect(stellarServiceMock.createTrustline).toHaveBeenCalledWith('S_MOCK_SECRET', 'USDC', 'G_USDC_ISSUER'); + expect(stellarServiceMock.createTrustline).toHaveBeenCalledWith(expect.any(Buffer), 'USDC', 'G_USDC_ISSUER'); expect(whatsappServiceMock.sendMessage).not.toHaveBeenCalled(); }); it('should notify the user via WhatsApp when the trustline reserve is insufficient', async () => { config.USDC_ISSUER_PUBLIC_KEY = 'G_USDC_ISSUER'; stellarServiceMock.createTrustline.mockRejectedValueOnce(new InsufficientReserveError()); - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); prismaClientMock.user.create.mockResolvedValueOnce({ id: '6', phoneNumber: '4444444444' }); await userService.getOrCreateUser('4444444444'); @@ -174,7 +176,7 @@ describe('UserService', () => { it('should log and continue when trustline creation fails for an unrelated reason', async () => { config.USDC_ISSUER_PUBLIC_KEY = 'G_USDC_ISSUER'; stellarServiceMock.createTrustline.mockRejectedValueOnce(new Error('Horizon is down')); - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); const createdUser = { id: '7', phoneNumber: '5555555555' }; prismaClientMock.user.create.mockResolvedValueOnce(createdUser); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); @@ -189,7 +191,7 @@ describe('UserService', () => { }); it('should use supported locale if provided', async () => { - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); prismaClientMock.user.create.mockResolvedValueOnce({ id: '5', phoneNumber: '3333333333', language: 'fr' }); await userService.getOrCreateUser('3333333333', 'fr'); @@ -204,7 +206,7 @@ describe('UserService', () => { }); it('should fallback to en if unsupported locale is provided', async () => { - prismaClientMock.user.findUnique.mockResolvedValueOnce(null); + prismaClientMock.user.findFirst.mockResolvedValueOnce(null); prismaClientMock.user.create.mockResolvedValueOnce({ id: '6', phoneNumber: '4444444444', language: 'en' }); await userService.getOrCreateUser('4444444444', 'xyz'); @@ -221,12 +223,13 @@ describe('UserService', () => { describe('updateUserLanguage', () => { it('should update user language', async () => { + prismaClientMock.user.findFirst.mockResolvedValueOnce({ id: '1', phoneNumber: '1234567890' }); prismaClientMock.user.update.mockResolvedValueOnce({ id: '1', phoneNumber: '1234567890', language: 'fr' }); await userService.updateUserLanguage('1234567890', 'fr'); expect(prismaClientMock.user.update).toHaveBeenCalledWith({ - where: { phoneNumber: '1234567890' }, + where: { id: '1' }, data: { language: 'fr' } }); }); @@ -242,9 +245,9 @@ describe('UserService', () => { }); it('should resolve by phone number if target does not start with @', async () => { - prismaClientMock.user.findUnique.mockResolvedValueOnce({ phoneNumber: '123' }); + prismaClientMock.user.findFirst.mockResolvedValueOnce({ phoneNumber: '123' }); await userService.resolveUser('123'); - expect(prismaClientMock.user.findUnique).toHaveBeenCalledWith({ + expect(prismaClientMock.user.findFirst).toHaveBeenCalledWith({ where: { phoneNumber: '123' } }); }); diff --git a/src/__tests__/wallet.util.test.ts b/src/__tests__/wallet.util.test.ts new file mode 100644 index 0000000..18e3028 --- /dev/null +++ b/src/__tests__/wallet.util.test.ts @@ -0,0 +1,52 @@ +import { parseStellarWallet } from '../utils/wallet.util'; + +const validBlob = { + publicKey: 'G_PUB', + encryptedSecret: 'ENC_SEC', + iv: 'IV', + authTag: 'TAG', + keyVersion: 1, +}; + +describe('parseStellarWallet', () => { + it('reports an absent wallet for a null, undefined or blank column', () => { + expect(parseStellarWallet(null)).toEqual({ status: 'absent' }); + expect(parseStellarWallet(undefined)).toEqual({ status: 'absent' }); + expect(parseStellarWallet('')).toEqual({ status: 'absent' }); + expect(parseStellarWallet(' ')).toEqual({ status: 'absent' }); + }); + + it('returns the wallet blob for a canonical row', () => { + expect(parseStellarWallet(JSON.stringify(validBlob))).toEqual({ + status: 'ok', + wallet: validBlob, + }); + }); + + it('drops a non-numeric keyVersion instead of rejecting the blob', () => { + const { keyVersion: _keyVersion, ...rest } = validBlob; + expect(parseStellarWallet(JSON.stringify({ ...rest, keyVersion: '1' }))).toEqual({ + status: 'ok', + wallet: rest, + }); + }); + + const unreadableCases: Array<[string, string]> = [ + ['truncated JSON', '{"publicKey":"G_PUB",'], + ['a pre-encryption bare public key', 'G_PUB'], + ['a JSON string', '"G_PUB"'], + ['a JSON array', '["G_PUB"]'], + ['JSON null', 'null'], + ['an object without publicKey', JSON.stringify({ ...validBlob, publicKey: undefined })], + ['an object with a blank publicKey', JSON.stringify({ ...validBlob, publicKey: ' ' })], + ['an object without the encrypted secret', JSON.stringify({ ...validBlob, encryptedSecret: undefined })], + ]; + + it.each(unreadableCases)('reports unreadable for %s', (_label, raw) => { + expect(parseStellarWallet(raw)).toEqual({ status: 'unreadable' }); + }); + + it('never throws on arbitrary non-JSON input', () => { + expect(() => parseStellarWallet('not json at all \u0000')).not.toThrow(); + }); +}); diff --git a/src/locales/en.json b/src/locales/en.json index 7212801..f58194f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -125,6 +125,7 @@ "exceeded": "⚠️ You're sending too many requests. Please wait {{remainingSeconds}} seconds." }, "wallet": { + "unreadable": "⚠️ We couldn't read your saved wallet. Please set up a new wallet or contact support.", "usdc_trustline_low_reserve": "You need at least 2 XLM in your wallet to enable USDC." }, "payment_request": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 2a6a6b5..e483c57 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -96,6 +96,7 @@ "exceeded": "⚠️ Vous envoyez trop de demandes. Veuillez patienter {{remainingSeconds}} secondes." }, "wallet": { + "unreadable": "⚠️ Nous n'avons pas pu lire votre portefeuille enregistré. Veuillez configurer un nouveau portefeuille ou contacter le support.", "usdc_trustline_low_reserve": "Vous avez besoin d'au moins 2 XLM dans votre portefeuille pour activer l'USDC." } } diff --git a/src/locales/ha.json b/src/locales/ha.json index 2943a25..56381e1 100644 --- a/src/locales/ha.json +++ b/src/locales/ha.json @@ -107,6 +107,7 @@ "success": "✅ An canza harshe cikin nasara!" }, "wallet": { + "unreadable": "⚠️ Ba mu iya karanta walat ɗinka da aka adana ba. Don Allah saita sabon walat ko tuntuɓi tallafi.", "usdc_trustline_low_reserve": "Kuna buƙatar aƙalla XLM 2 a cikin walat ɗinku don kunna USDC." } } diff --git a/src/locales/ig.json b/src/locales/ig.json index a40a22a..010556f 100644 --- a/src/locales/ig.json +++ b/src/locales/ig.json @@ -107,6 +107,7 @@ "success": "✅ Agbanwela asụsụ ahụ nke ọma!" }, "wallet": { + "unreadable": "⚠️ Anyị enweghị ike ịgụ obere akpa gị echekwara. Biko hazigharịa obere akpa ọhụrụ ma ọ bụ kpọtụrụ nkwado.", "usdc_trustline_low_reserve": "Ị chọrọ opekata mpe 2 XLM na obere akpa gị iji mee ka USDC rụọ ọrụ." } } diff --git a/src/locales/pcm.json b/src/locales/pcm.json index ce6e486..e60a011 100644 --- a/src/locales/pcm.json +++ b/src/locales/pcm.json @@ -107,6 +107,7 @@ "success": "✅ You don change language successfully!" }, "wallet": { + "unreadable": "⚠️ We no fit read the wallet wey you save. Abeg set up a new wallet or contact support.", "usdc_trustline_low_reserve": "You need at least 2 XLM for your wallet make you fit use USDC." } } diff --git a/src/locales/yo.json b/src/locales/yo.json index 3b9346c..4e689f9 100644 --- a/src/locales/yo.json +++ b/src/locales/yo.json @@ -96,6 +96,7 @@ "exceeded": "⚠️ O n kọlu awọn ibeere pupọ ju. Jọwọ duro fun iṣẹju-aaya {{remainingSeconds}}." }, "wallet": { + "unreadable": "⚠️ A kò le kà ápamọ́wọ́ rẹ tí a fipamọ́. Jọ̀wọ́ ṣètò ápamọ́wọ́ tuntun tàbí kàn sí àtìlẹ́yìn.", "usdc_trustline_low_reserve": "O nílò ó kéré tán 2 XLM nínú àpamọ́wọ́ rẹ láti mú USDC ṣiṣẹ́." } } diff --git a/src/services/message-processor.service.ts b/src/services/message-processor.service.ts index 801931f..fc6c51e 100644 --- a/src/services/message-processor.service.ts +++ b/src/services/message-processor.service.ts @@ -9,6 +9,7 @@ import { GroupExitService, GroupExitError } from './group-exit.service'; import { PaymentRequestService, PaymentRequestError } from './payment-request.service'; import { scheduleRequestCompletion, scheduleRequestExpiry } from '../queue/payment-request.queue'; import { decrypt } from '../utils/encryption.util'; +import { parseStellarWallet } from '../utils/wallet.util'; import { t, isSupportedLanguage, loadLocale } from './locale.service'; import { redisClient } from '../lib/redis'; import { registerSecret, unregisterSecret, zeroAllInFlightSecrets } from '../utils/secret-registry'; @@ -179,10 +180,15 @@ export class MessageProcessor { const user = await this.userService.getOrCreateUser(from); const lang = user.language ?? 'en'; - if (!user.stellarWallet) { + const wallet = parseStellarWallet(user.stellarWallet); + if (wallet.status === 'absent') { return await this.whatsappService.sendMessage(from, t('balance.no_wallet', lang)); } - const { publicKey } = JSON.parse(user.stellarWallet); + if (wallet.status === 'unreadable') { + return await this.whatsappService.sendMessage(from, t('wallet.unreadable', lang)); + } + + const { publicKey } = wallet.wallet; const balances = await this.stellarService.checkBalance(publicKey); const assetFilter = args[0]?.toUpperCase(); @@ -203,9 +209,13 @@ export class MessageProcessor { const user = await this.userService.getOrCreateUser(from); const lang = user.language ?? 'en'; - if (!user.stellarWallet) { + const wallet = parseStellarWallet(user.stellarWallet); + if (wallet.status === 'absent') { return await this.whatsappService.sendMessage(from, t('history.no_wallet', lang)); } + if (wallet.status === 'unreadable') { + return await this.whatsappService.sendMessage(from, t('wallet.unreadable', lang)); + } const isMore = args.length > 0 && args[0].toUpperCase() === 'MORE'; let cursor = undefined; @@ -222,7 +232,7 @@ export class MessageProcessor { await this.whatsappService.sendMessage(from, t('history.fetching', lang, { phone: from })); - const { publicKey } = JSON.parse(user.stellarWallet); + const { publicKey } = wallet.wallet; try { const history = await this.stellarService.getTransactionHistory(publicKey, cursor, 10); @@ -293,7 +303,11 @@ export class MessageProcessor { private async handleProfile(from: string) { const user = await this.userService.getOrCreateUser(from); const lang = user.language ?? 'en'; - const publicKey = user.stellarWallet ? JSON.parse(user.stellarWallet).publicKey : 'None'; + const wallet = parseStellarWallet(user.stellarWallet); + if (wallet.status === 'unreadable') { + return await this.whatsappService.sendMessage(from, t('wallet.unreadable', lang)); + } + const publicKey = wallet.status === 'ok' ? wallet.wallet.publicKey : 'None'; await this.whatsappService.sendMessage( from, t('profile.card', lang, { @@ -319,25 +333,30 @@ export class MessageProcessor { } const target = args[1]; - if (!user.stellarWallet) { + const senderWalletResult = parseStellarWallet(user.stellarWallet); + if (senderWalletResult.status === 'absent') { return await this.whatsappService.sendMessage(from, t('send.no_wallet', lang)); } + if (senderWalletResult.status === 'unreadable') { + return await this.whatsappService.sendMessage(from, t('wallet.unreadable', lang)); + } const recipient = await this.userService.resolveUser(target); - if (!recipient || !recipient.stellarWallet) { + const recipientWallet = parseStellarWallet(recipient?.stellarWallet); + if (!recipient || recipientWallet.status !== 'ok') { return await this.whatsappService.sendMessage( from, t('send.no_recipient', lang, { target }), ); } - const senderWallet = JSON.parse(user.stellarWallet); + const senderWallet = senderWalletResult.wallet; let senderSecret: Buffer | null = null; try { senderSecret = decrypt(senderWallet.encryptedSecret, senderWallet.iv, senderWallet.authTag, senderWallet.keyVersion || user.encryptionKeyVersion); registerSecret(senderSecret); - const recipientPublicKey = JSON.parse(recipient.stellarWallet).publicKey; + const recipientPublicKey = recipientWallet.wallet.publicKey; await this.whatsappService.sendMessage( from, @@ -830,14 +849,18 @@ export class MessageProcessor { ); } - if (!user.stellarWallet) { + const senderWalletResult = parseStellarWallet(user.stellarWallet); + if (senderWalletResult.status === 'absent') { return await this.whatsappService.sendMessage(from, t('send.no_wallet', lang)); } + if (senderWalletResult.status === 'unreadable') { + return await this.whatsappService.sendMessage(from, t('wallet.unreadable', lang)); + } if (!group.stellarContractId) { return await this.whatsappService.sendMessage(from, t('error.generic', lang, { message: 'Group has no Soroban contract configured.' })); } - const senderWallet = JSON.parse(user.stellarWallet); + const senderWallet = senderWalletResult.wallet; let senderSecret: Buffer | null = null; try { diff --git a/src/utils/wallet.util.ts b/src/utils/wallet.util.ts new file mode 100644 index 0000000..86a282a --- /dev/null +++ b/src/utils/wallet.util.ts @@ -0,0 +1,78 @@ +/** + * Guarded access to the `User.stellarWallet` JSON blob. + * + * The column is nullable text (see prisma/schema.prisma), so a historical row + * can be missing, truncated, written in the pre-encryption format (a bare + * public key), or simply hold something that is not JSON. A bare JSON.parse() + * in a handler turns any of those into a SyntaxError that escapes to the + * top-level catch and is reported to the user as an internal error. + * + * Every handler reads the column through parseStellarWallet() so that an + * unreadable blob is a known, user-facing condition instead of a crash. + */ + +/** Canonical shape of a row written by the wallet provisioning flow. */ +export interface StellarWalletBlob { + publicKey: string; + encryptedSecret: string; + iv: string; + authTag: string; + keyVersion?: number; +} + +export type WalletParseResult = + /** No wallet has been stored for this user (NULL/empty column). */ + | { status: 'absent' } + /** Something is stored, but it is not a usable wallet blob. */ + | { status: 'unreadable' } + /** The blob parsed and carries the fields handlers need. */ + | { status: 'ok'; wallet: StellarWalletBlob }; + +const REQUIRED_FIELDS = ['publicKey', 'encryptedSecret', 'iv', 'authTag'] as const; + +/** + * Parses a `stellarWallet` value without ever throwing. + * + * Returns `absent` only when the column is genuinely empty; anything that + * cannot be turned into a complete wallet blob is reported as `unreadable`. + */ +export function parseStellarWallet(raw: string | null | undefined): WalletParseResult { + if (raw === null || raw === undefined || raw.trim() === '') { + return { status: 'absent' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { status: 'unreadable' }; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { status: 'unreadable' }; + } + + const candidate = parsed as Record; + for (const field of REQUIRED_FIELDS) { + const value = candidate[field]; + if (typeof value !== 'string' || value.trim() === '') { + return { status: 'unreadable' }; + } + } + + // keyVersion is optional and only meaningful when it is a number; a + // non-numeric value is ignored so the caller falls back to the user's + // encryptionKeyVersion instead of failing the whole lookup. + const keyVersion = typeof candidate.keyVersion === 'number' ? candidate.keyVersion : undefined; + + return { + status: 'ok', + wallet: { + publicKey: candidate.publicKey as string, + encryptedSecret: candidate.encryptedSecret as string, + iv: candidate.iv as string, + authTag: candidate.authTag as string, + keyVersion, + }, + }; +}