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 f9b47c5093f756c8779e7b353eb7dcfee51d05a1 Mon Sep 17 00:00:00 2001 From: shindy Date: Sat, 17 Oct 2026 18:51:07 +0100 Subject: [PATCH 2/2] fix: fix(stellar): report a failed balance lookup instead of an e (#250) --- .../message-processor.service.test.ts | 36 +++++++++++++++++++ src/__tests__/stellar.service.test.ts | 12 +++++-- src/locales/en.json | 26 +++++++------- src/services/message-processor.service.ts | 17 ++++++++- src/services/stellar.service.ts | 7 +++- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/__tests__/message-processor.service.test.ts b/src/__tests__/message-processor.service.test.ts index 0085402..b89c750 100644 --- a/src/__tests__/message-processor.service.test.ts +++ b/src/__tests__/message-processor.service.test.ts @@ -171,6 +171,28 @@ describe('MessageProcessor', () => { ); }); + it('should report an unavailable balance when the lookup fails', async () => { + mockCheckBalance.mockRejectedValueOnce(new Error('Horizon unavailable')); + await processor.processCommand('12345', 'BALANCE'); + expect(mockSendMessage).toHaveBeenCalledWith( + '12345', + expect.stringContaining('balance.unavailable'), + ); + }); + + it('should render an empty balance when the account has no funds', async () => { + mockCheckBalance.mockResolvedValueOnce([]); + await processor.processCommand('12345', 'BALANCE'); + expect(mockSendMessage).toHaveBeenCalledWith( + '12345', + expect.stringContaining('balance.success'), + ); + expect(mockSendMessage).not.toHaveBeenCalledWith( + '12345', + expect.stringContaining('balance.unavailable'), + ); + }); + it('should handle PROFILE command', async () => { await processor.processCommand('12345', 'PROFILE'); expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('profile.card')); @@ -744,6 +766,20 @@ describe('MessageProcessor', () => { await processor.processCommand('12345', 'BALANCE'); expect(mockSendMessage).toHaveBeenCalledWith('12345', expect.stringContaining('balance.no_wallet')); }); + + it('should distinguish a failed lookup from an unfunded account', async () => { + mockCheckBalance.mockRejectedValueOnce(new Error('Horizon unavailable')); + await processor.processCommand('12345', 'BALANCE'); + const [, failedMessage] = mockSendMessage.mock.calls[0]; + expect(failedMessage).toContain('balance.unavailable'); + + mockSendMessage.mockClear(); + mockCheckBalance.mockResolvedValueOnce([]); + await processor.processCommand('12345', 'BALANCE'); + const [, emptyMessage] = mockSendMessage.mock.calls[0]; + expect(emptyMessage).toContain('balance.success'); + expect(emptyMessage).not.toContain('balance.unavailable'); + }); }); describe('payment requests (issue #65)', () => { diff --git a/src/__tests__/stellar.service.test.ts b/src/__tests__/stellar.service.test.ts index e14b045..e15e839 100644 --- a/src/__tests__/stellar.service.test.ts +++ b/src/__tests__/stellar.service.test.ts @@ -169,13 +169,19 @@ describe('StellarService', () => { ]); }); - it('should return an empty array when the account lookup fails', async () => { + it('should return an empty array when the account has no funds', async () => { + mServer.loadAccount.mockResolvedValueOnce({ balances: [] }); + + const balances = await stellarService.checkBalance('G_MOCK'); + expect(balances).toEqual([]); + }); + + it('should report a failed lookup instead of an empty balance', async () => { mServer.loadAccount.mockRejectedValueOnce(new Error('network unavailable')); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); try { - const balances = await stellarService.checkBalance('G_MOCK'); - expect(balances).toEqual([]); + await expect(stellarService.checkBalance('G_MOCK')).rejects.toThrow(); expect(consoleSpy).toHaveBeenCalledWith('Error checking balance:', expect.any(Error)); } finally { consoleSpy.mockRestore(); diff --git a/src/locales/en.json b/src/locales/en.json index 7212801..e331d9a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2,11 +2,13 @@ "balance": { "no_wallet": "[BALANCE] Error: Wallet not configured.", "success": "💰 Your Balances:\n{{balances}}", - "asset_not_found": "You don't have a trustline for {{asset}}." + "asset_not_found": "You don't have a trustline for {{asset}}.", + "not_funded": "Your wallet hasn't been funded yet -- you currently hold no funds.", + "unavailable": "Your balance is temporarily unavailable. Please try again later." }, "history": { "fetching": "[HISTORY] Fetching transaction history for {{phone}}...", - "header": "📜 Transaction History (Last 10):\n\n", + "header": "🜜 Transaction History (Last 10):\n\n", "item_sent": "{{index}}. ✅ Sent {{amount}} {{asset}} to {{counterparty}}\n {{date}} • tx: {{hash}}", "item_received": "{{index}}. 📥 Received {{amount}} {{asset}} from {{counterparty}}\n {{date}} • tx: {{hash}}", "item_other": "{{index}}. 🔄 {{type}} ({{amount}} {{asset}})\n {{date}} • tx: {{hash}}", @@ -28,14 +30,14 @@ "failed": "❌ [SEND] Failed: {{message}}" }, "request": { - "usage": "Usage: REQUEST <@username or phone>", + "usage": "Usage: REQUEST [amount> <@username or phone>", "no_user": "Error: Could not find user {{target}}.", - "notify_recipient": "🔔 [REQUEST] {{sender}} is requesting {{amount}} XLM from you.\n\nReply with:\nACCEPT {{requestId}} to approve (funds are held securely in escrow)\nDECLINE {{requestId}} to reject", + "notify_recipient": "🔕 [REQUEST] {{sender}} is requesting {{amount}} XLM from you.\n\nReply with:\nACCEPT {{requestId}} to approve (funds are held securely in escrow)\nDECLINE {{requestId}} to reject", "confirmation": "[REQUEST] Request for {{amount}} XLM sent to {{target}}." }, "create_group": { "usage": "Usage: CREATE GROUP ", - "success": "✅ Group \"{{name}}\" created!\nGroup ID: {{id}}", + "success": "✅ Group \" {{name}}\" created!\nGroup ID: {{id}}", "failed": "❌ Failed to create group: {{message}}" }, "join_group": { @@ -47,7 +49,7 @@ "usage": "Usage: INVITE MEMBER <@username or phone>", "no_user": "Error: Could not find user {{target}}.", "not_creator": "Error: You are not the creator of any group.", - "notify_recipient": "🔔 [INVITE] {{sender}} invited you to join their savings group \"{{groupName}}\".\n\nReply with: JOIN GROUP {{groupId}}", + "notify_recipient": "🔕 [INVITE] {{sender}} invited you to join their savings group \"{{groupName}}\".\n\nReply with: JOIN GROUP {{groupId}}", "success": "✅ Invite sent to {{target}}." }, "group_status": { @@ -75,7 +77,7 @@ "error_cannot_kick_self": "Error: You can't kick yourself — use LEAVE GROUP instead." }, "payout": { - "usage": "Usage: PAYOUT ", + "usage": "Usage: PAYOUT ", "no_group": "Error: You are not part of any group.", "not_creator": "Error: You are not the creator of any group.", "order_usage": "Usage: PAYOUT ORDER <@user1> <@user2> ... (list every member exactly once)", @@ -96,9 +98,9 @@ "usage": "Usage: CONTRIBUTE ", "no_group": "Error: You are not part of any group.", "amount_mismatch": "❌ Contribution must be exactly {{required}} XLM as required by this group.", - "group_paused": "❌ \"{{groupName}}\" is paused — it needs at least 2 members before contributions can resume.", - "initiating": "[CONTRIBUTE] Initiating contribution of {{amount}} XLM to \"{{groupName}}\"...", - "success": "✅ Successfully contributed {{amount}} XLM to \"{{groupName}}\"!", + "group_paused": "❌ \" {{groupName}}\" is paused — it needs at least 2 members before contributions can resume.", + "initiating": "[CONTRIBUTE] Initiating contribution of {{amount}} XLM to \" {{groupName}}\"...", + "success": "✅ Successfully contributed {{amount}} XLM to \" {{groupName}}\"!", "failed": "❌ Contribution failed: {{message}}" }, "validation": { @@ -110,7 +112,7 @@ "withdraw": { "usage": "Usage: WITHDRAW ", "no_group": "Error: You are not part of any group.", - "success": "✅ Successfully withdrew {{amount}} XLM from \"{{groupName}}\" pool! (MOCKED)" + "success": "✅ Successfully withdrew {{amount}} XLM from \" {{groupName}}\" pool! (MOCKED)" }, "help": { "text": "*Kolo Commands:*\n\n_Account_\nBALANCE\nBALANCE \nHISTORY\nPROFILE\n\n_Payments_\nSEND <@user>\nREQUEST <@user>\n\n_Savings Groups_\nCREATE GROUP \nJOIN GROUP \nINVITE MEMBER <@user>\nGROUP STATUS\nCONTRIBUTE \nWITHDRAW \nLEAVE GROUP \nKICK <@user> \n\n_Payout Rotation_\nPAYOUT ORDER <@user1> <@user2> ...\nPAYOUT STATUS\nPAYOUT WAIT\nPAYOUT PROCEED\nPAYOUT SKIP <@user>\n\n_Support_\nHELP\nSUPPORT" @@ -131,7 +133,7 @@ "accept_usage": "Usage: ACCEPT ", "decline_usage": "Usage: DECLINE ", "accepted": "✅ Escrow hold placed! {{amount}} {{asset}} is now locked for this request.\n\nIf everything looks right, the requester will receive it in about {{minutes}} minutes.\n\nMade a mistake? You have {{reclaimHours}} hour to reclaim your funds from the escrow.\nTx: https://stellar.expert/explorer/testnet/tx/{{hash}}", - "declined": "🙎 {{respondent}} declined your request for {{amount}} {{asset}}.", + "declined": "🐎 {{respondent}} declined your request for {{amount}} {{asset}}.", "decline_confirmed": "Request declined. The requester has been notified.", "completed_requester": "💸 Your payment request of {{amount}} {{asset}} was completed and the funds are in your account.\nTx: https://stellar.expert/explorer/testnet/tx/{{hash}}", "completed_responder": "✅ The confirmation window closed and {{amount}} {{asset}} was released to the requester. Thanks for using Kolo!", diff --git a/src/services/message-processor.service.ts b/src/services/message-processor.service.ts index 801931f..d4fef29 100644 --- a/src/services/message-processor.service.ts +++ b/src/services/message-processor.service.ts @@ -183,7 +183,22 @@ export class MessageProcessor { return await this.whatsappService.sendMessage(from, t('balance.no_wallet', lang)); } const { publicKey } = JSON.parse(user.stellarWallet); - const balances = await this.stellarService.checkBalance(publicKey); + let balances; + try { + balances = await this.stellarService.checkBalance(publicKey); + } catch (error: any) { + console.error('Balance lookup failed:', error); + return await this.whatsappService.sendMessage( + from, + t('balance.unavailable', lang), + ); + } + + // An account with no funds still returns an empty list (not an error), + // so this renders as an empty balance rather than an unavailable one. + if (balances.length === 0) { + return await this.whatsappService.sendMessage(from, t('balance.empty', lang)); + } const assetFilter = args[0]?.toUpperCase(); const shown = assetFilter ? balances.filter((b) => b.assetCode === assetFilter) : balances; diff --git a/src/services/stellar.service.ts b/src/services/stellar.service.ts index 834d0d5..f563e76 100644 --- a/src/services/stellar.service.ts +++ b/src/services/stellar.service.ts @@ -88,6 +88,11 @@ export class StellarService { /** * Returns current asset balances for a given Stellar account. */ + /** + * Returns current asset balances for a given Stellar account. + * Throws when the balance lookup fails so callers can distinguish an + * unavailable balance from an account that genuinely holds no funds. + */ public async checkBalance(publicKey: string): Promise { try { const account = await this.server.loadAccount(publicKey); @@ -98,7 +103,7 @@ export class StellarService { })); } catch (error) { console.error('Error checking balance:', error); - return []; + throw error; } }