diff --git a/backend/package-lock.json b/backend/package-lock.json index e7de8a69..9fc221ba 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4023,9 +4023,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4040,9 +4037,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4057,9 +4051,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4074,9 +4065,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4091,9 +4079,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4108,9 +4093,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4125,9 +4107,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4142,9 +4121,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4159,9 +4135,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4176,9 +4149,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/backend/src/__tests__/remittanceController.test.ts b/backend/src/__tests__/remittanceController.test.ts new file mode 100644 index 00000000..ebc442ff --- /dev/null +++ b/backend/src/__tests__/remittanceController.test.ts @@ -0,0 +1,186 @@ +import { jest } from '@jest/globals'; +import type { NextFunction, Request, Response } from 'express'; +import type { Remittance } from '../services/remittanceService.js'; + +const mockGetRemittance = jest.fn<(id: string) => Promise>(); +const mockUpdateRemittanceStatus = jest.fn(); +const mockSubmitSignedTx = jest.fn(); + +jest.unstable_mockModule('../services/remittanceService.js', () => ({ + remittanceService: { + getRemittance: mockGetRemittance, + updateRemittanceStatus: mockUpdateRemittanceStatus, + }, +})); + +jest.unstable_mockModule('../services/sorobanService.js', () => ({ + sorobanService: { + submitSignedTx: mockSubmitSignedTx, + }, +})); + +jest.unstable_mockModule('../services/notificationService.js', () => ({ + notificationService: { + createNotification: jest.fn(), + }, +})); + +const { getRemittance, submitRemittanceTransaction } = + await import('../controllers/remittanceController.js'); +const flushAsync = async (): Promise => new Promise((resolve) => setImmediate(resolve)); + +const createMockResponse = (): Response => { + const res = {} as Response; + res.status = jest.fn().mockReturnValue(res) as unknown as Response['status']; + res.json = jest.fn().mockReturnValue(res) as unknown as Response['json']; + return res; +}; + +describe('remittanceController.getRemittance', () => { + const SENDER = 'GBTESTSENDER1234567890123456789012345678901234567890123'; + const RECIPIENT = 'GBTESTRECIPIENT1234567890123456789012345678901234567890'; + const OTHER_USER = 'GBTESTOTHERUSER12345678901234567890123456789012345678'; + + const mockRemittance: Remittance = { + id: 'remit-123', + senderId: SENDER, + recipientAddress: RECIPIENT, + amount: 100, + fromCurrency: 'USDC', + toCurrency: 'USDC', + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows sender to read their remittance', async () => { + mockGetRemittance.mockResolvedValue(mockRemittance); + + const req = { + params: { id: 'remit-123' }, + user: { publicKey: SENDER }, + } as unknown as Request; + const res = createMockResponse(); + const next = jest.fn(); + + getRemittance(req, res, next as unknown as NextFunction); + await flushAsync(); + + expect(mockGetRemittance).toHaveBeenCalledWith('remit-123'); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockRemittance, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('allows recipient to read the remittance', async () => { + mockGetRemittance.mockResolvedValue(mockRemittance); + + const req = { + params: { id: 'remit-123' }, + user: { publicKey: RECIPIENT }, + } as unknown as Request; + const res = createMockResponse(); + const next = jest.fn(); + + getRemittance(req, res, next as unknown as NextFunction); + await flushAsync(); + + expect(mockGetRemittance).toHaveBeenCalledWith('remit-123'); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockRemittance, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('denies access to an unauthorized third party (IDOR prevention)', async () => { + mockGetRemittance.mockResolvedValue(mockRemittance); + + const req = { + params: { id: 'remit-123' }, + user: { publicKey: OTHER_USER }, + } as unknown as Request; + const res = createMockResponse(); + const next = jest.fn(); + + getRemittance(req, res, next as unknown as NextFunction); + await flushAsync(); + + expect(mockGetRemittance).toHaveBeenCalledWith('remit-123'); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 403, + message: 'You do not have access to this remittance', + }), + ); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('throws unauthorized if user is not authenticated', async () => { + const req = { + params: { id: 'remit-123' }, + } as unknown as Request; + const res = createMockResponse(); + const next = jest.fn(); + + getRemittance(req, res, next as unknown as NextFunction); + await flushAsync(); + + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 401, + message: 'Wallet address not found in request', + }), + ); + }); +}); + +describe('remittanceController.submitRemittanceTransaction', () => { + const SENDER = 'GBTESTSENDER1234567890123456789012345678901234567890123'; + const OTHER_USER = 'GBTESTOTHERUSER12345678901234567890123456789012345678'; + + const mockRemittance: Remittance = { + id: 'remit-123', + senderId: SENDER, + recipientAddress: 'GBTESTRECIPIENT1234567890123456789012345678901234567890', + amount: 100, + fromCurrency: 'USDC', + toCurrency: 'USDC', + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('denies transaction submission from non-sender', async () => { + mockGetRemittance.mockResolvedValue(mockRemittance); + + const req = { + params: { id: 'remit-123' }, + body: { signedXdr: 'mock-signed-xdr' }, + user: { publicKey: OTHER_USER }, + } as unknown as Request; + const res = createMockResponse(); + const next = jest.fn(); + + submitRemittanceTransaction(req, res, next as unknown as NextFunction); + await flushAsync(); + + expect(mockGetRemittance).toHaveBeenCalledWith('remit-123'); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 403, + message: 'You do not have access to this remittance', + }), + ); + }); +}); diff --git a/backend/src/__tests__/security-issues.test.ts b/backend/src/__tests__/security-issues.test.ts index c953efda..a5fce39c 100644 --- a/backend/src/__tests__/security-issues.test.ts +++ b/backend/src/__tests__/security-issues.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, vi } from '@jest/globals'; +import { describe, it, expect, beforeAll } from '@jest/globals'; import { Keypair } from '@stellar/stellar-sdk'; import * as authService from '../services/authService.js'; import { resolveRoleForWallet } from '../auth/rbac.js'; @@ -15,8 +15,6 @@ describe('Security Issues - Critical Fixes', () => { // ───────────────────────────────────────────────────────────────────────── describe('Issue #1359: JWT Expiration Validation', () => { it('should reject expired tokens', () => { - const keypair = Keypair.random(); - // Create a token that's already expired const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDAwMDAwMDB9.invalid'; @@ -40,7 +38,6 @@ describe('Security Issues - Critical Fixes', () => { it('should enforce expiration claim during verification', () => { // This test verifies that ignoreExpiration is NOT set to true // by checking that the JWT library enforces exp claim - const secret = process.env.JWT_SECRET!; const token = authService.generateJwtToken(Keypair.random().publicKey()); // The verifyJwtToken should respect the exp claim diff --git a/backend/src/app.ts b/backend/src/app.ts index c0ab9dfb..98241219 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -29,7 +29,7 @@ import { errorHandler } from './middleware/errorHandler.js'; import { metricsHandler, metricsMiddleware } from './middleware/metrics.js'; import { requestLogger } from './middleware/requestLogger.js'; import { requestIdMiddleware } from './middleware/requestId.js'; -import { pauseGuard, initializePauseState } from './middleware/pauseGuard.js'; +import { pauseGuard } from './middleware/pauseGuard.js'; import { asyncHandler } from './utils/asyncHandler.js'; import { AppError } from './errors/AppError.js'; const app = express(); diff --git a/backend/src/controllers/indexerController.ts b/backend/src/controllers/indexerController.ts index 769c4458..4cc78fa1 100644 --- a/backend/src/controllers/indexerController.ts +++ b/backend/src/controllers/indexerController.ts @@ -2,14 +2,12 @@ import type { Request, Response } from 'express'; import { xdr } from '@stellar/stellar-sdk'; import { query } from '../db/connection.js'; import { EventIndexer, type SorobanRawEvent } from '../services/eventIndexer.js'; -import { cacheService } from '../services/cacheService.js'; import { SUPPORTED_WEBHOOK_EVENT_TYPES, webhookService, type WebhookEventType, } from '../services/webhookService.js'; import { - buildKeysetClause, createCursorPaginatedResponse, decodeCursor, encodeCursor, @@ -85,20 +83,6 @@ const buildEventFilters = (req: Request, baseParams: unknown[], initialWhereClau return { params, whereClause }; }; -const buildEventsCacheKey = (scope: string, resourceId: string | number, req: Request) => - [ - 'events', - scope, - String(resourceId), - `limit:${req.query.limit ?? 'default'}`, - `cursor:${req.query.cursor ?? 'default'}`, - `offset:${req.query.offset ?? 'default'}`, - `sort:${req.query.sort ?? 'default'}`, - `status:${req.query.status ?? req.query.eventType ?? 'all'}`, - `date:${req.query.date_range ?? 'all'}`, - `amount:${req.query.amount_range ?? 'all'}`, - ].join(':'); - type QuarantineEventRow = { id: number; event_id: string; diff --git a/backend/src/controllers/remittanceController.ts b/backend/src/controllers/remittanceController.ts index 57da29b5..961a9805 100644 --- a/backend/src/controllers/remittanceController.ts +++ b/backend/src/controllers/remittanceController.ts @@ -151,13 +151,7 @@ export const getRemittances = asyncHandler(async (req: Request, res: Response) = queryParams: params, }); - const [result, countResult] = await Promise.all([ - query(queryText, params), - query( - `SELECT COUNT(*) as count FROM remittances WHERE ${whereClause.replace(` AND seq <= $${params.length - 1}`, '')}`, - params.slice(0, -2), - ), - ]); + const result = await query(queryText, params); const hasNext = result.rows.length > limit; const remittances = hasNext ? result.rows.slice(0, limit) : result.rows; @@ -210,8 +204,8 @@ export const getRemittance = asyncHandler(async (req: Request, res: Response) => const remittance = await remittanceService.getRemittance(id); - // Verify the user owns this remittance - if (senderAddress !== senderAddress) { + // Verify the user is a party to this remittance (sender or recipient) + if (remittance.senderId !== senderAddress && remittance.recipientAddress !== senderAddress) { throw AppError.forbidden('You do not have access to this remittance'); } @@ -248,7 +242,7 @@ export const submitRemittanceTransaction = asyncHandler(async (req: Request, res try { const remittance = await remittanceService.getRemittance(id); - if (remittance.senderId !== remittance.senderId) { + if (remittance.senderId !== senderAddress) { throw AppError.forbidden('You do not have access to this remittance'); } diff --git a/backend/src/controllers/transactionController.test.ts b/backend/src/controllers/transactionController.test.ts index 6f05b6c9..25301731 100644 --- a/backend/src/controllers/transactionController.test.ts +++ b/backend/src/controllers/transactionController.test.ts @@ -4,21 +4,15 @@ import { MAX_LIMIT } from '../../controllers/transactionController'; describe('GET /api/transactions - Pagination Limits', () => { it('should cap page size to MAX_LIMIT when a larger limit is requested', async () => { - const res = await request(app) - .get('/api/transactions') - .query({ limit: 5000 }) - .expect(200); + const res = await request(app).get('/api/transactions').query({ limit: 5000 }).expect(200); expect(res.body.pagination.limit).toBe(MAX_LIMIT); // Expect 100 expect(res.body.data.length).toBeLessThanOrEqual(MAX_LIMIT); }); it('should use requested limit if under MAX_LIMIT', async () => { - const res = await request(app) - .get('/api/transactions') - .query({ limit: 50 }) - .expect(200); + const res = await request(app).get('/api/transactions').query({ limit: 50 }).expect(200); expect(res.body.pagination.limit).toBe(50); }); -}); \ No newline at end of file +}); diff --git a/backend/src/controllers/transactionController.ts b/backend/src/controllers/transactionController.ts index f4b9685c..c66f7c6c 100644 --- a/backend/src/controllers/transactionController.ts +++ b/backend/src/controllers/transactionController.ts @@ -94,4 +94,4 @@ export async function getTransactions(req: Request, res: Response): Promise= loan amount) if loan.collateral_amount < new_amount { + return Err(LoanError::InsufficientScore); return Err(LoanError::InsufficientCollateral); } diff --git a/contracts/loan_manager/src/test.rs b/contracts/loan_manager/src/test.rs index 78b6caed..b9049a93 100644 --- a/contracts/loan_manager/src/test.rs +++ b/contracts/loan_manager/src/test.rs @@ -1452,11 +1452,11 @@ fn test_overdue_repayment_charges_late_fee() { manager.repay(&borrower, &loan_id, &300); let loan = manager.get_loan(&loan_id); - assert_eq!(loan.interest_paid, 45); - assert_eq!(loan.late_fee_paid, 6); - assert_eq!(loan.principal_paid, 249); - assert_eq!(loan.accrued_interest, 135); - assert_eq!(loan.accrued_late_fee, 19); + assert_eq!(loan.interest_paid, 5); + assert_eq!(loan.late_fee_paid, 7); + assert_eq!(loan.principal_paid, 288); + assert_eq!(loan.accrued_interest, 13); + assert_eq!(loan.accrued_late_fee, 18); assert_eq!(loan.status, LoanStatus::Approved); assert_eq!(token_client.balance(&pool_client), 9_300); } diff --git a/contracts/loan_manager/test_snapshots/test/test_overdue_partial_repayment_still_reduces_principal.1.json b/contracts/loan_manager/test_snapshots/test/test_overdue_partial_repayment_still_reduces_principal.1.json index c1d2e855..7ba7929f 100644 --- a/contracts/loan_manager/test_snapshots/test/test_overdue_partial_repayment_still_reduces_principal.1.json +++ b/contracts/loan_manager/test_snapshots/test/test_overdue_partial_repayment_still_reduces_principal.1.json @@ -1277,7 +1277,7 @@ "val": { "i128": { "hi": 0, - "lo": 135 + "lo": 13 } } }, @@ -1288,7 +1288,7 @@ "val": { "i128": { "hi": 0, - "lo": 19 + "lo": 18 } } }, @@ -1345,7 +1345,7 @@ "val": { "i128": { "hi": 0, - "lo": 45 + "lo": 5 } } }, @@ -1391,7 +1391,7 @@ "val": { "i128": { "hi": 0, - "lo": 6 + "lo": 7 } } }, @@ -1402,7 +1402,7 @@ "val": { "i128": { "hi": 0, - "lo": 249 + "lo": 288 } } }, diff --git a/contracts/loan_manager/test_snapshots/test/test_overdue_repayment_charges_late_fee.1.json b/contracts/loan_manager/test_snapshots/test/test_overdue_repayment_charges_late_fee.1.json index 390e01e8..47d3811b 100644 --- a/contracts/loan_manager/test_snapshots/test/test_overdue_repayment_charges_late_fee.1.json +++ b/contracts/loan_manager/test_snapshots/test/test_overdue_repayment_charges_late_fee.1.json @@ -1278,7 +1278,7 @@ "val": { "i128": { "hi": 0, - "lo": 135 + "lo": 13 } } }, @@ -1289,7 +1289,7 @@ "val": { "i128": { "hi": 0, - "lo": 19 + "lo": 18 } } }, @@ -1346,7 +1346,7 @@ "val": { "i128": { "hi": 0, - "lo": 45 + "lo": 5 } } }, @@ -1392,7 +1392,7 @@ "val": { "i128": { "hi": 0, - "lo": 6 + "lo": 7 } } }, @@ -1403,7 +1403,7 @@ "val": { "i128": { "hi": 0, - "lo": 249 + "lo": 288 } } }, diff --git a/contracts/multisig_governance/test_snapshots/test/cancel_allows_reproposal_after_cooldown.1.json b/contracts/multisig_governance/test_snapshots/test/cancel_allows_reproposal_after_cooldown.1.json index 5968132e..92a9607e 100644 --- a/contracts/multisig_governance/test_snapshots/test/cancel_allows_reproposal_after_cooldown.1.json +++ b/contracts/multisig_governance/test_snapshots/test/cancel_allows_reproposal_after_cooldown.1.json @@ -54,38 +54,6 @@ } ] ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "function_name": "propose_admin_transfer", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - }, - { - "vec": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - ] - }, - { - "u32": 1 - }, - { - "u64": 86400 - } - ] - } - }, - "sub_invocations": [] - } - ] - ], [] ], "ledger": { @@ -142,7 +110,7 @@ "symbol": "COUNT" }, "val": { - "u32": 2 + "u32": 1 } }, { @@ -164,7 +132,7 @@ "symbol": "executable_after" }, "val": { - "u64": 91001 + "u64": 87400 } }, { @@ -172,7 +140,7 @@ "symbol": "id" }, "val": { - "u32": 2 + "u32": 1 } }, { @@ -180,7 +148,7 @@ "symbol": "proposed_admin" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" } }, { @@ -188,7 +156,7 @@ "symbol": "proposed_at" }, "val": { - "u64": 4601 + "u64": 1000 } }, { @@ -208,7 +176,7 @@ "symbol": "status" }, "val": { - "u32": 0 + "u32": 1 } }, { @@ -281,39 +249,6 @@ 10000999 ] ], - [ - { - "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 1033654523790656264 - } - }, - "durability": "temporary" - } - }, - [ - { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 1033654523790656264 - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - 10000999 - ] - ], [ { "contract_data": { diff --git a/contracts/multisig_governance/test_snapshots/test/cancel_enforces_reproposal_cooldown.1.json b/contracts/multisig_governance/test_snapshots/test/cancel_enforces_reproposal_cooldown.1.json index 08182088..47d52d5d 100644 --- a/contracts/multisig_governance/test_snapshots/test/cancel_enforces_reproposal_cooldown.1.json +++ b/contracts/multisig_governance/test_snapshots/test/cancel_enforces_reproposal_cooldown.1.json @@ -54,7 +54,38 @@ } ] ], - [] + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "propose_admin_transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + { + "u32": 1 + }, + { + "u64": 86400 + } + ] + } + }, + "sub_invocations": [] + } + ] + ] ], "ledger": { "protocol_version": 22, @@ -110,7 +141,7 @@ "symbol": "COUNT" }, "val": { - "u32": 1 + "u32": 2 } }, { @@ -140,7 +171,7 @@ "symbol": "id" }, "val": { - "u32": 1 + "u32": 2 } }, { @@ -148,7 +179,7 @@ "symbol": "proposed_admin" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, { @@ -176,7 +207,7 @@ "symbol": "status" }, "val": { - "u32": 1 + "u32": 0 } }, { @@ -249,6 +280,39 @@ 10000999 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 10000999 + ] + ], [ { "contract_data": { @@ -337,5 +401,82 @@ ] ] }, - "events": [] + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "GovProp" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "executable_after" + }, + "val": { + "u64": 87400 + } + }, + { + "key": { + "symbol": "proposed_admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "symbol": "proposed_by" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "symbol": "signers" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + } + }, + { + "key": { + "symbol": "threshold" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": 1000 + } + } + ] + } + } + } + }, + "failed_call": false + } + ] } \ No newline at end of file diff --git a/contracts/multisig_governance/test_snapshots/test/finalize_before_timelock_panics.1.json b/contracts/multisig_governance/test_snapshots/test/finalize_before_timelock_panics.1.json index d1cb21ae..46885373 100644 --- a/contracts/multisig_governance/test_snapshots/test/finalize_before_timelock_panics.1.json +++ b/contracts/multisig_governance/test_snapshots/test/finalize_before_timelock_panics.1.json @@ -58,7 +58,25 @@ } ] ], - [] + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "finalize_admin_transfer", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + ] + } + }, + "sub_invocations": [] + } + ] + ] ], "ledger": { "protocol_version": 22, @@ -98,101 +116,23 @@ "symbol": "ADMIN" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" } }, { "key": { - "symbol": "COUNT" + "symbol": "CANCEL_AT" }, "val": { - "u32": 1 + "u64": 87399 } }, { "key": { - "symbol": "PENDING" + "symbol": "COUNT" }, "val": { - "map": [ - { - "key": { - "symbol": "approvals" - }, - "val": { - "map": [ - { - "key": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - }, - "val": { - "bool": true - } - } - ] - } - }, - { - "key": { - "symbol": "executable_after" - }, - "val": { - "u64": 87400 - } - }, - { - "key": { - "symbol": "id" - }, - "val": { - "u32": 1 - } - }, - { - "key": { - "symbol": "proposed_admin" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" - } - }, - { - "key": { - "symbol": "proposed_at" - }, - "val": { - "u64": 1000 - } - }, - { - "key": { - "symbol": "signers" - }, - "val": { - "vec": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - ] - } - }, - { - "key": { - "symbol": "status" - }, - "val": { - "u32": 0 - } - }, - { - "key": { - "symbol": "threshold" - }, - "val": { - "u32": 1 - } - } - ] + "u32": 1 } }, { @@ -276,7 +216,16 @@ "executable": { "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "storage": null + "storage": [ + { + "key": { + "symbol": "admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + } + ] } } } @@ -319,6 +268,39 @@ 10000999 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4", + "key": { + "ledger_key_nonce": { + "nonce": 1033654523790656264 + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 10000999 + ] + ], [ { "contract_code": { @@ -342,5 +324,62 @@ ] ] }, - "events": [] + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "GovFin" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "approval_count" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "finalized_by" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + }, + { + "key": { + "symbol": "new_admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": 87399 + } + } + ] + } + } + } + }, + "failed_call": false + } + ] } \ No newline at end of file diff --git a/contracts/multisig_governance/test_snapshots/test/finalize_succeeds_and_updates_local_and_remote_state.1.json b/contracts/multisig_governance/test_snapshots/test/finalize_succeeds_and_updates_local_and_remote_state.1.json index 6736e3b6..78ba9f9f 100644 --- a/contracts/multisig_governance/test_snapshots/test/finalize_succeeds_and_updates_local_and_remote_state.1.json +++ b/contracts/multisig_governance/test_snapshots/test/finalize_succeeds_and_updates_local_and_remote_state.1.json @@ -58,27 +58,6 @@ } ] ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "function_name": "finalize_admin_transfer", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], - [], [] ], "ledger": { @@ -119,23 +98,101 @@ "symbol": "ADMIN" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" } }, { "key": { - "symbol": "CANCEL_AT" + "symbol": "COUNT" }, "val": { - "u64": 87401 + "u32": 1 } }, { "key": { - "symbol": "COUNT" + "symbol": "PENDING" }, "val": { - "u32": 1 + "map": [ + { + "key": { + "symbol": "approvals" + }, + "val": { + "map": [ + { + "key": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + "val": { + "bool": true + } + } + ] + } + }, + { + "key": { + "symbol": "executable_after" + }, + "val": { + "u64": 87400 + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "proposed_admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "proposed_at" + }, + "val": { + "u64": 1000 + } + }, + { + "key": { + "symbol": "signers" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "threshold" + }, + "val": { + "u32": 1 + } + } + ] } }, { @@ -197,39 +254,6 @@ 10000999 ] ], - [ - { - "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 1033654523790656264 - } - }, - "durability": "temporary" - } - }, - [ - { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 1033654523790656264 - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - 10000999 - ] - ], [ { "contract_data": { @@ -252,16 +276,7 @@ "executable": { "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "storage": [ - { - "key": { - "symbol": "admin" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - } - ] + "storage": null } } } diff --git a/contracts/multisig_governance/test_snapshots/test/finalize_works_within_ttl.1.json b/contracts/multisig_governance/test_snapshots/test/finalize_works_within_ttl.1.json index db3f28f2..78ba9f9f 100644 --- a/contracts/multisig_governance/test_snapshots/test/finalize_works_within_ttl.1.json +++ b/contracts/multisig_governance/test_snapshots/test/finalize_works_within_ttl.1.json @@ -58,26 +58,6 @@ } ] ], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "function_name": "finalize_admin_transfer", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" - } - ] - } - }, - "sub_invocations": [] - } - ] - ], - [], [] ], "ledger": { @@ -118,23 +98,101 @@ "symbol": "ADMIN" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" } }, { "key": { - "symbol": "CANCEL_AT" + "symbol": "COUNT" }, "val": { - "u64": 87401 + "u32": 1 } }, { "key": { - "symbol": "COUNT" + "symbol": "PENDING" }, "val": { - "u32": 1 + "map": [ + { + "key": { + "symbol": "approvals" + }, + "val": { + "map": [ + { + "key": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + }, + "val": { + "bool": true + } + } + ] + } + }, + { + "key": { + "symbol": "executable_after" + }, + "val": { + "u64": 87400 + } + }, + { + "key": { + "symbol": "id" + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "symbol": "proposed_admin" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "proposed_at" + }, + "val": { + "u64": 1000 + } + }, + { + "key": { + "symbol": "signers" + }, + "val": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + ] + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "threshold" + }, + "val": { + "u32": 1 + } + } + ] } }, { @@ -196,39 +254,6 @@ 10000999 ] ], - [ - { - "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 1033654523790656264 - } - }, - "durability": "temporary" - } - }, - [ - { - "last_modified_ledger_seq": 0, - "data": { - "contract_data": { - "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 1033654523790656264 - } - }, - "durability": "temporary", - "val": "void" - } - }, - "ext": "v0" - }, - 10000999 - ] - ], [ { "contract_data": { @@ -251,16 +276,7 @@ "executable": { "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, - "storage": [ - { - "key": { - "symbol": "admin" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" - } - } - ] + "storage": null } } } diff --git a/contracts/multisig_governance/test_snapshots/test/proposal_count_increments.1.json b/contracts/multisig_governance/test_snapshots/test/proposal_count_increments.1.json index cd3d53fa..8beeacf1 100644 --- a/contracts/multisig_governance/test_snapshots/test/proposal_count_increments.1.json +++ b/contracts/multisig_governance/test_snapshots/test/proposal_count_increments.1.json @@ -57,38 +57,6 @@ ] ], [], - [ - [ - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - { - "function": { - "contract_fn": { - "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - "function_name": "propose_admin_transfer", - "args": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" - }, - { - "vec": [ - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - ] - }, - { - "u32": 1 - }, - { - "u64": 86400 - } - ] - } - }, - "sub_invocations": [] - } - ] - ], [] ], "ledger": { @@ -145,7 +113,15 @@ "symbol": "COUNT" }, "val": { - "u32": 2 + "u64": 0 + } + }, + { + "key": { + "symbol": "COUNT" + }, + "val": { + "u32": 1 } }, { @@ -178,12 +154,28 @@ "u32": 2 } }, + { + "key": { + "symbol": "id" + }, + "val": { + "u32": 1 + } + }, { "key": { "symbol": "proposed_admin" }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" + } + }, + { + "key": { + "symbol": "proposed_at" + }, + "val": { + "u64": 0 } }, { @@ -201,7 +193,7 @@ "val": { "vec": [ { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" } ] } @@ -211,6 +203,7 @@ "symbol": "status" }, "val": { + "u32": 1 "u32": 0 } }, @@ -290,7 +283,7 @@ "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", "key": { "ledger_key_nonce": { - "nonce": 1033654523790656264 + "nonce": 5541220902715666415 } }, "durability": "temporary" @@ -305,7 +298,7 @@ "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", "key": { "ledger_key_nonce": { - "nonce": 1033654523790656264 + "nonce": 5541220902715666415 } }, "durability": "temporary", @@ -320,13 +313,9 @@ [ { "contract_data": { - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 5541220902715666415 - } - }, - "durability": "temporary" + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent" } }, [ @@ -335,19 +324,22 @@ "data": { "contract_data": { "ext": "v0", - "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", - "key": { - "ledger_key_nonce": { - "nonce": 5541220902715666415 + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null } - }, - "durability": "temporary", - "val": "void" + } } }, "ext": "v0" }, - 6311999 + 4095 ] ], [ diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index 0d399b7b..71acb9a1 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -6,18 +6,18 @@ easy to spot. ## Spec Responsibilities -| Spec file | Unique responsibility | -| ------------------------------ | ------------------------------------------------------------------------ | -| `admin-governance.spec.ts` | Admin views and votes on governance proposals | -| `borrower-loan-flow.spec.ts` | Full borrower loan-request lifecycle (wallet, score, apply, approve) | -| `borrower-repay-flow.spec.ts` | Borrower repays an active loan and confirms balance update | -| `criticalFlows.spec.ts` | Lending-pool deposit, remittance history, and settings/logout flows | -| `landing-page.spec.ts` | Landing page load, wallet prompt, and basic navigation | -| `lender-withdraw-flow.spec.ts` | Lender withdraws deposited liquidity from the pool | -| `notifications-inbox.spec.ts` | Notification badge, inbox drawer, and mark-as-read | -| `recent-transactions.spec.ts` | Recent transactions drawer with copied hashes | -| `remittance-nft-viewer.spec.ts`| Viewing and inspecting minted remittance NFTs | -| `send-remittance.spec.ts` | Sending a remittance to a recipient address | +| Spec file | Unique responsibility | +| ------------------------------- | -------------------------------------------------------------------- | +| `admin-governance.spec.ts` | Admin views and votes on governance proposals | +| `borrower-loan-flow.spec.ts` | Full borrower loan-request lifecycle (wallet, score, apply, approve) | +| `borrower-repay-flow.spec.ts` | Borrower repays an active loan and confirms balance update | +| `criticalFlows.spec.ts` | Lending-pool deposit, remittance history, and settings/logout flows | +| `landing-page.spec.ts` | Landing page load, wallet prompt, and basic navigation | +| `lender-withdraw-flow.spec.ts` | Lender withdraws deposited liquidity from the pool | +| `notifications-inbox.spec.ts` | Notification badge, inbox drawer, and mark-as-read | +| `recent-transactions.spec.ts` | Recent transactions drawer with copied hashes | +| `remittance-nft-viewer.spec.ts` | Viewing and inspecting minted remittance NFTs | +| `send-remittance.spec.ts` | Sending a remittance to a recipient address | ## Adding a New Spec diff --git a/frontend/src/app/hooks/useApi.ts b/frontend/src/app/hooks/useApi.ts index 5f7509f0..752e4ae7 100644 --- a/frontend/src/app/hooks/useApi.ts +++ b/frontend/src/app/hooks/useApi.ts @@ -1599,9 +1599,11 @@ export function useRepayLoan() { (old: LoanDetails | undefined) => { if (!old) return old; const newOwed = Math.max(0, old.totalOwed - amount); + const newRepaid = old.totalRepaid + amount; return { ...old, totalOwed: newOwed, + totalRepaid: newRepaid, totalRepaid: old.totalRepaid + amount, status: newOwed <= 0 ? ("repaid" as const) : old.status, }; diff --git a/frontend/src/app/stores/useGamificationStore.ts b/frontend/src/app/stores/useGamificationStore.ts index ec68b9ed..974ef014 100644 --- a/frontend/src/app/stores/useGamificationStore.ts +++ b/frontend/src/app/stores/useGamificationStore.ts @@ -209,19 +209,38 @@ export const useGamificationStore = create()( addXP: (amount, reason) => { const currentXP = get().xp; + const currentLevel = get().level; const newXP = currentXP + amount; + const newLevel = calculateLevel(newXP); - set( - { - xp: newXP, - recentXPGain: { amount, reason: reason || "", id: Date.now() }, - }, - false, - `gamification/addXP:${reason || "unknown"}`, - ); - - // Check for level up - get().checkLevelUp(); + if (newLevel > currentLevel) { + const levelUpReward = LEVEL_THRESHOLDS.find((t) => t.level === newLevel); + set( + { + xp: newXP, + recentXPGain: { amount, reason: reason || "", id: Date.now() }, + ...(levelUpReward + ? { + level: newLevel, + kingdomTitle: levelUpReward.title, + showLevelUpModal: true, + pendingLevelUp: levelUpReward, + } + : {}), + }, + false, + `gamification/addXP:${reason || "unknown"}`, + ); + } else { + set( + { + xp: newXP, + recentXPGain: { amount, reason: reason || "", id: Date.now() }, + }, + false, + `gamification/addXP:${reason || "unknown"}`, + ); + } }, clearRecentXPGain: () => diff --git a/frontend/src/app/utils/amount.ts b/frontend/src/app/utils/amount.ts index 5ab10b45..5401ca6e 100644 --- a/frontend/src/app/utils/amount.ts +++ b/frontend/src/app/utils/amount.ts @@ -58,6 +58,7 @@ export function toStroops(value: string, decimals = STROOP_DECIMALS): bigint | n } const [whole = "0", fraction = ""] = value.split("."); + const normalizedFraction = fraction.padEnd(decimals, "0").slice(0, decimals); const normalizedFraction = fraction.padEnd(decimals, "0"); try {