From 3f862af4f0d1a084d064e5c3ec5027327602b5b9 Mon Sep 17 00:00:00 2001 From: khaylebfortune <111098422+khaylebfortune@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:50:16 +0000 Subject: [PATCH 1/3] fix: align repay function name between XDR builder and transaction checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed creditline contract function is 'repay_installment' but the transaction status checker was looking for 'repay_loan'. This mismatch meant successful on-chain repayments never triggered the follow-up loan balance deduction — loans appeared unpaid indefinitely. Changes: - transaction-status-checker.service.ts: check for 'repay_installment' - creditline.client.ts: fix error log to match actual function name - loans.controller.ts, loan-payment-response.dto.ts: update Swagger docs - Add alignment test to prevent future drift Closes #62 --- .../transaction-status-checker.service.ts | 2 +- .../loans/dto/loan-payment-response.dto.ts | 2 +- src/modules/loans/loans.controller.ts | 2 +- .../contracts/clients/creditline.client.ts | 2 +- .../stellar/contract-function-name.spec.ts | 40 +++++++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 test/unit/stellar/contract-function-name.spec.ts diff --git a/src/jobs/transaction-status-checker/transaction-status-checker.service.ts b/src/jobs/transaction-status-checker/transaction-status-checker.service.ts index fcc8dee..54fb3dd 100644 --- a/src/jobs/transaction-status-checker/transaction-status-checker.service.ts +++ b/src/jobs/transaction-status-checker/transaction-status-checker.service.ts @@ -535,7 +535,7 @@ export class TransactionStatusCheckerService { }; } - if (functionName === 'repay_loan') { + if (functionName === 'repay_installment') { const loanId = nativeArgs[1] as string; const rawAmount = nativeArgs[2]; const amount = typeof rawAmount === 'bigint' ? Number(rawAmount) / 10_000_000 : diff --git a/src/modules/loans/dto/loan-payment-response.dto.ts b/src/modules/loans/dto/loan-payment-response.dto.ts index 7680ee2..62f68fb 100644 --- a/src/modules/loans/dto/loan-payment-response.dto.ts +++ b/src/modules/loans/dto/loan-payment-response.dto.ts @@ -27,7 +27,7 @@ export class LoanPaymentPreviewDto { */ export class LoanPaymentResponseDto { @ApiProperty({ - description: 'Unsigned XDR transaction for the repay_loan() Soroban call', + description: 'Unsigned XDR transaction for the repay_installment() Soroban call', example: 'AAAAAgAAAAA...', }) unsignedXdr: string; diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 7b9d253..3837aaf 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -184,7 +184,7 @@ export class LoansController { @ApiOperation({ summary: 'Make a loan repayment', description: - 'Validates the payment, constructs an unsigned Soroban repay_loan() transaction, and returns it alongside a payment preview. The mobile app must sign the XDR and submit the signed transaction back to the network. Requires JWT authentication.', + 'Validates the payment, constructs an unsigned Soroban repay_installment() transaction, and returns it alongside a payment preview. The mobile app must sign the XDR and submit the signed transaction back to the network. Requires JWT authentication.', }) @ApiResponse({ status: 200, diff --git a/src/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index ddd231d..a6a391a 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -97,7 +97,7 @@ export class CreditLineContractClient { if (error instanceof ContractNotConfiguredError) { throw error; } - this.logger.error(`Failed to build repay_loan transaction: ${error.message}`); + this.logger.error(`Failed to build repay_installment transaction: ${error.message}`); throw new ContractTxBuildError('repayment'); } } diff --git a/test/unit/stellar/contract-function-name.spec.ts b/test/unit/stellar/contract-function-name.spec.ts new file mode 100644 index 0000000..f105d1f --- /dev/null +++ b/test/unit/stellar/contract-function-name.spec.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +describe('Repayment function name alignment', () => { + const creditlinePath = path.join( + __dirname, + '../../../src/stellar/contracts/clients/creditline.client.ts', + ); + const checkerPath = path.join( + __dirname, + '../../../src/jobs/transaction-status-checker/transaction-status-checker.service.ts', + ); + + it('XDR builder and transaction checker use the same function name', () => { + const creditlineSource = fs.readFileSync(creditlinePath, 'utf8'); + const checkerSource = fs.readFileSync(checkerPath, 'utf8'); + + const xdrFnNames = creditlineSource.match(/contract\.call\('([^']+)'/g) || []; + const repayFnName = xdrFnNames + .map((m) => m.match(/contract\.call\('([^']+)'/)?.[1]) + .find((n) => n !== 'create_loan'); + const checkerFnNames = checkerSource.match(/functionName === '([^']+)'/g) || []; + const checkerRepayFnName = checkerFnNames + .map((m) => m.match(/functionName === '([^']+)'/)?.[1]) + .find((n) => n !== 'create_loan'); + + expect(repayFnName).toBeDefined(); + expect(checkerRepayFnName).toBeDefined(); + expect(repayFnName).toBe(checkerRepayFnName); + }); + + it('uses the correct contract function name: repay_installment', () => { + const creditlineSource = fs.readFileSync(creditlinePath, 'utf8'); + const xdrFnNames = creditlineSource.match(/contract\.call\('([^']+)'/g) || []; + const repayFnName = xdrFnNames + .map((m) => m.match(/contract\.call\('([^']+)'/)?.[1]) + .find((n) => n !== 'create_loan'); + expect(repayFnName).toBe('repay_installment'); + }); +}); From 106d27f94182fb9d549ff94863b50c03aa30d8fa Mon Sep 17 00:00:00 2001 From: khaylebfortune <111098422+khaylebfortune@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:56:37 +0000 Subject: [PATCH 2/3] fix: rename buildRepayLoanTx to buildRepayInstallmentTx, rewrite brittle test to assert on decoded XDR --- src/modules/loans/loans.service.ts | 2 +- .../contracts/clients/creditline.client.ts | 2 +- .../interfaces/creditline.interface.ts | 2 +- .../contracts/mocks/creditline.mock.ts | 2 +- test/e2e/helpers/test-setup.ts | 2 +- .../modules/loans/loan-lifecycle.e2e-spec.ts | 4 +- test/unit/modules/loans/loans.service.spec.ts | 4 +- .../stellar/contract-function-name.spec.ts | 94 ++++++++++++------- 8 files changed, 69 insertions(+), 43 deletions(-) diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index 91b3377..e862e2e 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -213,7 +213,7 @@ export class LoansService { }); } - return this.creditLineContractClient.buildRepayLoanTx( + return this.creditLineContractClient.buildRepayInstallmentTx( wallet, loan.loan_id, amount, diff --git a/src/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index a6a391a..4abc2c7 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -66,7 +66,7 @@ export class CreditLineContractClient { return prepared.toXDR(); } - async buildRepayLoanTx(userWallet: string, loanId: string, amount: number): Promise { + async buildRepayInstallmentTx(userWallet: string, loanId: string, amount: number): Promise { this.ensureConfigured(); try { diff --git a/src/stellar/contracts/interfaces/creditline.interface.ts b/src/stellar/contracts/interfaces/creditline.interface.ts index 8a60d2d..8fa0c85 100644 --- a/src/stellar/contracts/interfaces/creditline.interface.ts +++ b/src/stellar/contracts/interfaces/creditline.interface.ts @@ -16,7 +16,7 @@ export interface ICreditLineClient { params: CreateLoanParams, ): Promise; - buildRepayLoanTx( + buildRepayInstallmentTx( userWallet: string, loanId: string, amount: number, diff --git a/src/stellar/contracts/mocks/creditline.mock.ts b/src/stellar/contracts/mocks/creditline.mock.ts index 525d0c6..d91d66e 100644 --- a/src/stellar/contracts/mocks/creditline.mock.ts +++ b/src/stellar/contracts/mocks/creditline.mock.ts @@ -9,7 +9,7 @@ export class MockCreditLineContractClient { }, ); - buildRepayLoanTx = jest.fn( + buildRepayInstallmentTx = jest.fn( async (_userWallet: string, _loanId: string, _amount: number): Promise => { return 'AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; }, diff --git a/test/e2e/helpers/test-setup.ts b/test/e2e/helpers/test-setup.ts index e916e3b..ca54eda 100644 --- a/test/e2e/helpers/test-setup.ts +++ b/test/e2e/helpers/test-setup.ts @@ -320,7 +320,7 @@ export async function buildTestApp(): Promise<{ buildCreateLoanTransaction: jest .fn() .mockResolvedValue('AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='), - buildRepayLoanTx: jest + buildRepayInstallmentTx: jest .fn() .mockResolvedValue('AAAAAgAAAQAAAAAAAAAAiZ3TgwAAAAAyMZyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='), getScore: jest.fn().mockResolvedValue(75), diff --git a/test/e2e/modules/loans/loan-lifecycle.e2e-spec.ts b/test/e2e/modules/loans/loan-lifecycle.e2e-spec.ts index 1394594..d1581c5 100644 --- a/test/e2e/modules/loans/loan-lifecycle.e2e-spec.ts +++ b/test/e2e/modules/loans/loan-lifecycle.e2e-spec.ts @@ -75,7 +75,7 @@ describe('Loan Lifecycle Flow (e2e)', () => { const mockCreditLineContract = { buildCreateLoanTransaction: jest.fn(), - buildRepayLoanTx: jest.fn(), + buildRepayInstallmentTx: jest.fn(), }; const mockTransactionsService = { @@ -288,7 +288,7 @@ describe('Loan Lifecycle Flow (e2e)', () => { return `xdr-loan-create-${payload.loanId}`; }); - mockCreditLineContract.buildRepayLoanTx.mockImplementation(async (_wallet, loanId, amount) => { + mockCreditLineContract.buildRepayInstallmentTx.mockImplementation(async (_wallet, loanId, amount) => { return `xdr-loan-repay-${loanId}-${amount}`; }); diff --git a/test/unit/modules/loans/loans.service.spec.ts b/test/unit/modules/loans/loans.service.spec.ts index 2f82922..129eea0 100644 --- a/test/unit/modules/loans/loans.service.spec.ts +++ b/test/unit/modules/loans/loans.service.spec.ts @@ -110,7 +110,7 @@ describe('LoansService', () => { mockSupabaseFrom.insert.mockResolvedValue({ error: null }); mockSupabaseFrom.update.mockReturnThis(); mockCreditLineContractClient.buildCreateLoanTransaction.mockResolvedValue('AAAAAgAAAAC...'); - mockCreditLineContractClient.buildRepayLoanTx.mockResolvedValue('AAAAAgAAAAA...'); + mockCreditLineContractClient.buildRepayInstallmentTx.mockResolvedValue('AAAAAgAAAAA...'); }); afterEach(() => { @@ -386,7 +386,7 @@ describe('LoansService', () => { willComplete: false, }, }); - expect(mockCreditLineContractClient.buildRepayLoanTx).toHaveBeenCalledWith( + expect(mockCreditLineContractClient.buildRepayInstallmentTx).toHaveBeenCalledWith( validWallet, 'chain-loan-1', 108.33, diff --git a/test/unit/stellar/contract-function-name.spec.ts b/test/unit/stellar/contract-function-name.spec.ts index f105d1f..74a186e 100644 --- a/test/unit/stellar/contract-function-name.spec.ts +++ b/test/unit/stellar/contract-function-name.spec.ts @@ -1,40 +1,66 @@ -import * as fs from 'fs'; -import * as path from 'path'; +jest.unmock('stellar-sdk'); + +import * as StellarSdk from 'stellar-sdk'; +import { CreditLineContractClient } from '../../../src/stellar/contracts/clients/creditline.client'; +import { SorobanService } from '../../../src/blockchain/soroban/soroban.service'; +import { ConfigService } from '@nestjs/config'; describe('Repayment function name alignment', () => { - const creditlinePath = path.join( - __dirname, - '../../../src/stellar/contracts/clients/creditline.client.ts', - ); - const checkerPath = path.join( - __dirname, - '../../../src/jobs/transaction-status-checker/transaction-status-checker.service.ts', - ); - - it('XDR builder and transaction checker use the same function name', () => { - const creditlineSource = fs.readFileSync(creditlinePath, 'utf8'); - const checkerSource = fs.readFileSync(checkerPath, 'utf8'); - - const xdrFnNames = creditlineSource.match(/contract\.call\('([^']+)'/g) || []; - const repayFnName = xdrFnNames - .map((m) => m.match(/contract\.call\('([^']+)'/)?.[1]) - .find((n) => n !== 'create_loan'); - const checkerFnNames = checkerSource.match(/functionName === '([^']+)'/g) || []; - const checkerRepayFnName = checkerFnNames - .map((m) => m.match(/functionName === '([^']+)'/)?.[1]) - .find((n) => n !== 'create_loan'); - - expect(repayFnName).toBeDefined(); - expect(checkerRepayFnName).toBeDefined(); - expect(repayFnName).toBe(checkerRepayFnName); + let client: CreditLineContractClient; + let mockServer: { prepareTransaction: jest.Mock; getAccount: jest.Mock }; + + const testContractId = 'CC4C3VI4FS6J5PP7OQN4ZC3WWBYMAISPRN4VYTCZ2J7CHI3255OM32LT'; + const testNetwork = 'Test SDF Network ; September 2015'; + const testWallet = 'GAZBSUBUXTD6YVYXECZRW2R6K7MOU5FGU2SVYXKYWOLT5H3JIRD54EZL'; + + beforeEach(() => { + mockServer = { + prepareTransaction: jest.fn(async (tx: StellarSdk.Transaction) => { + return { toXDR: () => tx.toXDR() }; + }), + getAccount: jest.fn().mockResolvedValue({}), + }; + + const mockSorobanService = { + getServer: jest.fn().mockReturnValue(mockServer), + getNetworkPassphrase: jest.fn().mockReturnValue(testNetwork), + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'CREDIT_LINE_CONTRACT_ID' || key === 'CREDITLINE_CONTRACT_ID') { + return testContractId; + } + return undefined; + }), + }; + + client = new CreditLineContractClient( + mockSorobanService as unknown as SorobanService, + mockConfigService as unknown as ConfigService, + ); }); - it('uses the correct contract function name: repay_installment', () => { - const creditlineSource = fs.readFileSync(creditlinePath, 'utf8'); - const xdrFnNames = creditlineSource.match(/contract\.call\('([^']+)'/g) || []; - const repayFnName = xdrFnNames - .map((m) => m.match(/contract\.call\('([^']+)'/)?.[1]) - .find((n) => n !== 'create_loan'); - expect(repayFnName).toBe('repay_installment'); + it('XDR builder and transaction checker use the same function name: repay_installment with 3 args', async () => { + const xdr = await client.buildRepayInstallmentTx(testWallet, 'test-loan-123', 100); + + const tx = StellarSdk.TransactionBuilder.fromXDR(xdr, testNetwork); + const innerTx = + tx instanceof StellarSdk.FeeBumpTransaction ? tx.innerTransaction : tx; + const op = innerTx.operations[0]; + + expect(op.type).toBe('invokeHostFunction'); + + const invocation = (op.func as unknown as { + _value?: { _attributes?: { functionName?: { toString?: () => string }; args?: unknown[] } }; + })?._value?._attributes; + + expect(invocation).toBeDefined(); + + const functionName = invocation!.functionName?.toString?.(); + expect(functionName).toBe('repay_installment'); + + const args = invocation!.args as unknown[]; + expect(args).toHaveLength(3); }); }); From c90b642b072fc51492d7c870d428a74602d77088 Mon Sep 17 00:00:00 2001 From: khaylebfortune <111098422+khaylebfortune@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:01:48 +0000 Subject: [PATCH 3/3] fix: cast through unknown to avoid TS build error on op.func --- test/unit/stellar/contract-function-name.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/stellar/contract-function-name.spec.ts b/test/unit/stellar/contract-function-name.spec.ts index 74a186e..77e110e 100644 --- a/test/unit/stellar/contract-function-name.spec.ts +++ b/test/unit/stellar/contract-function-name.spec.ts @@ -51,7 +51,7 @@ describe('Repayment function name alignment', () => { expect(op.type).toBe('invokeHostFunction'); - const invocation = (op.func as unknown as { + const invocation = ((op as unknown as { func: unknown }).func as { _value?: { _attributes?: { functionName?: { toString?: () => string }; args?: unknown[] } }; })?._value?._attributes;