From 3cdea02358b90dc3b7113ecc89355c1e552cecbc Mon Sep 17 00:00:00 2001 From: Benjamin Gobeil Date: Wed, 8 Apr 2026 15:03:11 -0400 Subject: [PATCH 1/2] feat: add getTotalAmountDue and multicall batching for view commands - Add getTotalAmountDue ABI + view command for invoices - Change all view commands from --claim-id to --claim-ids (comma-separated) - Implement batch reader methods using viem multicall for single-RPC lookups - Add formatViewResults for multi-result output --- .../ports/frendlend-reader-port.ts | 13 ++ src/application/ports/invoice-reader-port.ts | 26 ++++ src/cli/formatters/view.ts | 5 + src/cli/frendlend/view-commands.ts | 41 ++++-- src/cli/invoice/view-commands.ts | 73 ++++++++-- src/cli/options/invoice-options.ts | 6 + src/infrastructure/abi/bulla-invoice.ts | 10 ++ .../reading/viem-frendlend-reader.ts | 105 ++++++++++++++ .../reading/viem-invoice-reader.ts | 134 ++++++++++++++++++ test/e2e/view-functions.e2e.test.ts | 6 +- 10 files changed, 390 insertions(+), 29 deletions(-) diff --git a/src/application/ports/frendlend-reader-port.ts b/src/application/ports/frendlend-reader-port.ts index b8da81e..f44c322 100644 --- a/src/application/ports/frendlend-reader-port.ts +++ b/src/application/ports/frendlend-reader-port.ts @@ -20,6 +20,19 @@ export interface FrendLendReaderService { chainId: ChainId, claimId: bigint, ): Effect.Effect<{ remainingPrincipal: bigint; grossInterest: bigint }, LoanNotFoundError | UnsupportedChainError | ContractNotFoundError>; + + getLoans( + chainId: ChainId, + claimIds: bigint[], + ): Effect.Effect; + + getTotalAmountsDue( + chainId: ChainId, + claimIds: bigint[], + ): Effect.Effect< + { remainingPrincipal: bigint; grossInterest: bigint }[], + LoanNotFoundError | UnsupportedChainError | ContractNotFoundError + >; } export const FrendLendReaderService = Context.GenericTag('@services/FrendLendReaderService'); diff --git a/src/application/ports/invoice-reader-port.ts b/src/application/ports/invoice-reader-port.ts index d519da4..ba4732a 100644 --- a/src/application/ports/invoice-reader-port.ts +++ b/src/application/ports/invoice-reader-port.ts @@ -17,6 +17,32 @@ export interface InvoiceReaderService { chainId: ChainId, claimId: bigint, ): Effect.Effect; + + getTotalAmountDue( + chainId: ChainId, + claimId: bigint, + ): Effect.Effect< + { remainingPrincipal: bigint; grossInterest: bigint }, + InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError + >; + + getInvoices( + chainId: ChainId, + claimIds: bigint[], + ): Effect.Effect; + + getDepositAmountsNeeded( + chainId: ChainId, + claimIds: bigint[], + ): Effect.Effect; + + getTotalAmountsDue( + chainId: ChainId, + claimIds: bigint[], + ): Effect.Effect< + { remainingPrincipal: bigint; grossInterest: bigint }[], + InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError + >; } export const InvoiceReaderService = Context.GenericTag('@services/InvoiceReaderService'); diff --git a/src/cli/formatters/view.ts b/src/cli/formatters/view.ts index 20f112d..c78f4c4 100644 --- a/src/cli/formatters/view.ts +++ b/src/cli/formatters/view.ts @@ -20,3 +20,8 @@ export const formatViewResult = (data: Record, format: OutputFo } return lines.join('\n'); }; + +export const formatViewResults = (data: Record[], format: OutputFormat): string => { + if (format === 'json') return JSON.stringify(data, bigintReplacer, 2); + return data.map((d, i) => `--- Result ${i + 1} ---\n${formatViewResult(d, format)}`).join('\n\n'); +}; diff --git a/src/cli/frendlend/view-commands.ts b/src/cli/frendlend/view-commands.ts index 0a7a4d5..c1a6645 100644 --- a/src/cli/frendlend/view-commands.ts +++ b/src/cli/frendlend/view-commands.ts @@ -5,7 +5,7 @@ import { BuildModeLayers, makeFrendLendReader } from '../../infrastructure/layer import type { OutputFormat } from '../formatters/index.js'; import { chainOption, formatOption, getChainId, requiredRpcUrlOption } from '../options/common.js'; import { offerIdOption } from '../options/frendlend-options.js'; -import { claimIdOption } from '../options/invoice-options.js'; +import { claimIdsOption, parseClaimIds } from '../options/invoice-options.js'; import type { LoanOfferOnChain, LoanOnChain } from '../../domain/types/frendlend.js'; // ============================================================================ @@ -39,6 +39,11 @@ const formatLoanAsHuman = (loan: LoanOnChain): string => { return lines.join('\n'); }; +const formatLoansAsJson = (loans: LoanOnChain[]): string => JSON.stringify(loans, bigintReplacer, 2); + +const formatLoansAsHuman = (loans: LoanOnChain[]): string => + loans.map(loan => formatLoanAsHuman(loan)).join('\n\n'); + const formatLoanOfferAsJson = (offer: LoanOfferOnChain): string => JSON.stringify(offer, bigintReplacer, 2); const formatLoanOfferAsHuman = (offer: LoanOfferOnChain): string => { @@ -74,6 +79,12 @@ const formatTotalDueAsHuman = (data: { remainingPrincipal: bigint; grossInterest return lines.join('\n'); }; +const formatTotalDuesAsJson = (data: { remainingPrincipal: bigint; grossInterest: bigint }[]): string => + JSON.stringify(data, bigintReplacer, 2); + +const formatTotalDuesAsHuman = (data: { remainingPrincipal: bigint; grossInterest: bigint }[]): string => + data.map(d => formatTotalDueAsHuman(d)).join('\n\n'); + // ============================================================================ // GET LOAN // ============================================================================ @@ -83,20 +94,24 @@ export const frendlendGetLoanCommand = Command.make( { chain: chainOption, rpcUrl: requiredRpcUrlOption, - claimId: claimIdOption, + claimIds: claimIdsOption, format: formatOption, }, - ({ chain, rpcUrl, claimId, format }) => + ({ chain, rpcUrl, claimIds: rawIds, format }) => Effect.gen(function* () { const chainId = yield* getChainId(chain, rpcUrl); + const ids = parseClaimIds(rawIds); const readerLayer = Layer.provide(makeFrendLendReader(rpcUrl), BuildModeLayers); const reader = yield* FrendLendReaderService.pipe(Effect.provide(readerLayer)); - const loan = yield* reader.getLoan(chainId, BigInt(claimId)); + const loans = yield* reader.getLoans(chainId, ids); - const output = format === 'json' ? formatLoanAsJson(loan) : formatLoanAsHuman(loan); + const output = + loans.length === 1 + ? format === 'json' ? formatLoanAsJson(loans[0]!) : formatLoanAsHuman(loans[0]!) + : format === 'json' ? formatLoansAsJson(loans) : formatLoansAsHuman(loans); yield* Console.log(output); }), -).pipe(Command.withDescription('Read loan details from on-chain by claim ID')); +).pipe(Command.withDescription('Read loan details from on-chain by claim ID(s)')); // ============================================================================ // GET OFFER @@ -131,20 +146,24 @@ export const frendlendTotalDueCommand = Command.make( { chain: chainOption, rpcUrl: requiredRpcUrlOption, - claimId: claimIdOption, + claimIds: claimIdsOption, format: formatOption, }, - ({ chain, rpcUrl, claimId, format }) => + ({ chain, rpcUrl, claimIds: rawIds, format }) => Effect.gen(function* () { const chainId = yield* getChainId(chain, rpcUrl); + const ids = parseClaimIds(rawIds); const readerLayer = Layer.provide(makeFrendLendReader(rpcUrl), BuildModeLayers); const reader = yield* FrendLendReaderService.pipe(Effect.provide(readerLayer)); - const totalDue = yield* reader.getTotalAmountDue(chainId, BigInt(claimId)); + const results = yield* reader.getTotalAmountsDue(chainId, ids); - const output = format === 'json' ? formatTotalDueAsJson(totalDue) : formatTotalDueAsHuman(totalDue); + const output = + results.length === 1 + ? format === 'json' ? formatTotalDueAsJson(results[0]!) : formatTotalDueAsHuman(results[0]!) + : format === 'json' ? formatTotalDuesAsJson(results) : formatTotalDuesAsHuman(results); yield* Console.log(output); }), -).pipe(Command.withDescription('Read the total amount due (remaining principal + gross interest) for a loan')); +).pipe(Command.withDescription('Read the total amount due (remaining principal + gross interest) for loan(s)')); // ============================================================================ // EXPORT ALL VIEW COMMANDS diff --git a/src/cli/invoice/view-commands.ts b/src/cli/invoice/view-commands.ts index 2e97041..04b5395 100644 --- a/src/cli/invoice/view-commands.ts +++ b/src/cli/invoice/view-commands.ts @@ -2,10 +2,10 @@ import { Command } from '@effect/cli'; import { Console, Effect } from 'effect'; import { InvoiceReaderService } from '../../application/ports/invoice-reader-port.js'; import { makeReaderLayer } from '../../infrastructure/layers.js'; -import { formatViewResult } from '../formatters/view.js'; +import { formatViewResult, formatViewResults } from '../formatters/view.js'; import type { OutputFormat } from '../formatters/index.js'; import { chainOption, formatOption, getChainId, requiredRpcUrlOption } from '../options/common.js'; -import { claimIdOption } from '../options/invoice-options.js'; +import { claimIdsOption, parseClaimIds } from '../options/invoice-options.js'; // ============================================================================ // GET INVOICE @@ -16,23 +16,25 @@ export const invoiceGetCommand = Command.make( { chain: chainOption, rpcUrl: requiredRpcUrlOption, - claimId: claimIdOption, + claimIds: claimIdsOption, format: formatOption, }, - ({ chain, rpcUrl, claimId, format }) => + ({ chain, rpcUrl, claimIds: rawIds, format }) => Effect.gen(function* () { const chainId = yield* getChainId(chain, rpcUrl); if (!chainId) return; + const ids = parseClaimIds(rawIds); const readerLayer = makeReaderLayer(rpcUrl); - const result = yield* InvoiceReaderService.pipe( - Effect.flatMap(reader => reader.getInvoice(chainId, BigInt(claimId))), + const results = yield* InvoiceReaderService.pipe( + Effect.flatMap(reader => reader.getInvoices(chainId, ids)), Effect.provide(readerLayer), ); - yield* Console.log(formatViewResult(result as unknown as Record, format as OutputFormat)); + const data = results.map((r, i) => ({ claimId: ids[i]!.toString(), ...r }) as unknown as Record); + yield* Console.log(data.length === 1 ? formatViewResult(data[0]!, format as OutputFormat) : formatViewResults(data, format as OutputFormat)); }), -).pipe(Command.withDescription('Read an invoice from on-chain by claim ID')); +).pipe(Command.withDescription('Read invoice(s) from on-chain by claim ID(s)')); // ============================================================================ // GET DEPOSIT NEEDED @@ -43,28 +45,69 @@ export const invoiceDepositNeededCommand = Command.make( { chain: chainOption, rpcUrl: requiredRpcUrlOption, - claimId: claimIdOption, + claimIds: claimIdsOption, format: formatOption, }, - ({ chain, rpcUrl, claimId, format }) => + ({ chain, rpcUrl, claimIds: rawIds, format }) => Effect.gen(function* () { const chainId = yield* getChainId(chain, rpcUrl); if (!chainId) return; + const ids = parseClaimIds(rawIds); const readerLayer = makeReaderLayer(rpcUrl); - const amount = yield* InvoiceReaderService.pipe( - Effect.flatMap(reader => reader.getTotalAmountNeededForPurchaseOrderDeposit(chainId, BigInt(claimId))), + const amounts = yield* InvoiceReaderService.pipe( + Effect.flatMap(reader => reader.getDepositAmountsNeeded(chainId, ids)), Effect.provide(readerLayer), ); + const data = amounts.map((amount, i) => ({ claimId: ids[i]!.toString(), depositAmountNeeded: amount })); yield* Console.log( - formatViewResult({ claimId, depositAmountNeeded: amount }, format as OutputFormat), + data.length === 1 + ? formatViewResult(data[0]!, format as OutputFormat) + : formatViewResults(data, format as OutputFormat), ); }), -).pipe(Command.withDescription('Get the total amount needed for a purchase order deposit')); +).pipe(Command.withDescription('Get the total amount needed for purchase order deposit(s)')); + +// ============================================================================ +// TOTAL AMOUNT DUE +// ============================================================================ + +export const invoiceTotalDueCommand = Command.make( + 'total-due', + { + chain: chainOption, + rpcUrl: requiredRpcUrlOption, + claimIds: claimIdsOption, + format: formatOption, + }, + ({ chain, rpcUrl, claimIds: rawIds, format }) => + Effect.gen(function* () { + const chainId = yield* getChainId(chain, rpcUrl); + if (!chainId) return; + + const ids = parseClaimIds(rawIds); + const readerLayer = makeReaderLayer(rpcUrl); + const results = yield* InvoiceReaderService.pipe( + Effect.flatMap(reader => reader.getTotalAmountsDue(chainId, ids)), + Effect.provide(readerLayer), + ); + + const data = results.map((r, i) => ({ + claimId: ids[i]!.toString(), + remainingPrincipal: r.remainingPrincipal.toString(), + grossInterest: r.grossInterest.toString(), + })); + yield* Console.log( + data.length === 1 + ? formatViewResult(data[0]!, format as OutputFormat) + : formatViewResults(data, format as OutputFormat), + ); + }), +).pipe(Command.withDescription('Get the total amount due (remaining principal + gross interest) for invoice(s)')); // ============================================================================ // EXPORT VIEW COMMANDS // ============================================================================ -export const invoiceViewCommands = [invoiceGetCommand, invoiceDepositNeededCommand] as const; +export const invoiceViewCommands = [invoiceGetCommand, invoiceDepositNeededCommand, invoiceTotalDueCommand] as const; diff --git a/src/cli/options/invoice-options.ts b/src/cli/options/invoice-options.ts index f44c97f..37f1ad1 100644 --- a/src/cli/options/invoice-options.ts +++ b/src/cli/options/invoice-options.ts @@ -3,6 +3,12 @@ import { Options } from '@effect/cli'; // Common invoice options export const claimIdOption = Options.integer('claim-id').pipe(Options.withDescription('The ID of the invoice/claim')); +export const claimIdsOption = Options.text('claim-ids').pipe( + Options.withDescription('Comma-separated claim IDs (e.g. 1,2,3)'), +); + +export const parseClaimIds = (raw: string): bigint[] => raw.split(',').map(id => BigInt(id.trim())); + export const debtorOption = Options.text('debtor').pipe(Options.withDescription('The debtor address (who owes the payment)')); export const creditorOption = Options.text('creditor').pipe(Options.withDescription('The creditor address (who receives the payment)')); diff --git a/src/infrastructure/abi/bulla-invoice.ts b/src/infrastructure/abi/bulla-invoice.ts index 5c6dd2a..bf6e490 100644 --- a/src/infrastructure/abi/bulla-invoice.ts +++ b/src/infrastructure/abi/bulla-invoice.ts @@ -209,4 +209,14 @@ export const bullaInvoiceAbi = [ stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [{ internalType: 'uint256', name: 'claimId', type: 'uint256' }], + name: 'getTotalAmountDue', + outputs: [ + { internalType: 'uint256', name: 'remainingPrincipal', type: 'uint256' }, + { internalType: 'uint256', name: 'grossInterest', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, ] as const; diff --git a/src/infrastructure/reading/viem-frendlend-reader.ts b/src/infrastructure/reading/viem-frendlend-reader.ts index 1bd0878..7947e0f 100644 --- a/src/infrastructure/reading/viem-frendlend-reader.ts +++ b/src/infrastructure/reading/viem-frendlend-reader.ts @@ -190,6 +190,111 @@ export const makeFrendLendReaderLayer = (rpcUrl: string) => return { remainingPrincipal: result[0], grossInterest: result[1] }; }), + + getLoans: (chainId: ChainId, claimIds: bigint[]) => + Effect.gen(function* () { + const contractAddress = yield* registry.getFrendLendAddress(chainId); + const chain = chainMap[chainId]; + const client = createPublicClient({ chain, transport: http(rpcUrl) }); + + const results = yield* Effect.tryPromise({ + try: () => + client.multicall({ + contracts: claimIds.map(claimId => ({ + address: contractAddress as Hex, + abi: bullaFrendLendV2Abi, + functionName: 'getLoan' as const, + args: [claimId] as const, + })), + allowFailure: false, + }), + catch: err => + new LoanNotFoundError({ + chainId, + claimId: claimIds[0]!, + message: `Failed to batch-read loans on chain ${chainId}: ${err}`, + }), + }); + + return results.map((r, i) => { + if (r.creditor === zeroAddress && r.debtor === zeroAddress) { + throw new LoanNotFoundError({ + chainId, + claimId: claimIds[i]!, + message: `Loan with claim ID ${claimIds[i]!} does not exist on chain ${chainId}`, + }); + } + return { + claimAmount: r.claimAmount, + paidAmount: r.paidAmount, + status: r.status, + binding: r.binding, + debtor: r.debtor.toLowerCase() as EthAddress, + creditor: r.creditor.toLowerCase() as EthAddress, + token: r.token.toLowerCase() as EthAddress, + controller: r.controller.toLowerCase() as EthAddress, + dueBy: r.dueBy, + acceptedAt: r.acceptedAt, + interestConfig: { + interestRateBps: r.interestConfig.interestRateBps, + numberOfPeriodsPerYear: r.interestConfig.numberOfPeriodsPerYear, + }, + interestComputationState: { + accruedInterest: r.interestComputationState.accruedInterest, + latestPeriodNumber: r.interestComputationState.latestPeriodNumber, + protocolFeeBps: r.interestComputationState.protocolFeeBps, + totalGrossInterestPaid: r.interestComputationState.totalGrossInterestPaid, + }, + } satisfies LoanOnChain; + }); + }), + + getTotalAmountsDue: (chainId: ChainId, claimIds: bigint[]) => + Effect.gen(function* () { + const contractAddress = yield* registry.getFrendLendAddress(chainId); + const chain = chainMap[chainId]; + const client = createPublicClient({ chain, transport: http(rpcUrl) }); + + // Batch getLoan (validation) + getTotalAmountDue for each claim in one multicall + const contracts = claimIds.flatMap(claimId => [ + { + address: contractAddress as Hex, + abi: bullaFrendLendV2Abi, + functionName: 'getLoan' as const, + args: [claimId] as const, + }, + { + address: contractAddress as Hex, + abi: bullaFrendLendV2Abi, + functionName: 'getTotalAmountDue' as const, + args: [claimId] as const, + }, + ]); + + const results = yield* Effect.tryPromise({ + try: () => client.multicall({ contracts, allowFailure: false }), + catch: err => + new LoanNotFoundError({ + chainId, + claimId: claimIds[0]!, + message: `Failed to batch-read total amounts due on chain ${chainId}: ${err}`, + }), + }); + + // Results interleaved: [loan0, due0, loan1, due1, ...] + return claimIds.map((claimId, i) => { + const loan = results[i * 2] as { creditor: string; debtor: string }; + if (loan.creditor === zeroAddress && loan.debtor === zeroAddress) { + throw new LoanNotFoundError({ + chainId, + claimId, + message: `Loan with claim ID ${claimId} does not exist on chain ${chainId}`, + }); + } + const due = results[i * 2 + 1] as readonly [bigint, bigint]; + return { remainingPrincipal: due[0], grossInterest: due[1] }; + }); + }), }; }), ); diff --git a/src/infrastructure/reading/viem-invoice-reader.ts b/src/infrastructure/reading/viem-invoice-reader.ts index 34e0243..674f225 100644 --- a/src/infrastructure/reading/viem-invoice-reader.ts +++ b/src/infrastructure/reading/viem-invoice-reader.ts @@ -104,6 +104,140 @@ export const makeInvoiceReaderLayer = (rpcUrl: string) => return result; }), + + getTotalAmountDue: (chainId: ChainId, claimId: bigint) => + Effect.gen(function* () { + const contractAddress = yield* registry.getInvoiceAddress(chainId); + const chain = chainMap[chainId]; + + const client = createPublicClient({ + chain, + transport: http(rpcUrl), + }); + + const result = yield* Effect.tryPromise({ + try: () => + client.readContract({ + address: contractAddress as Hex, + abi: bullaInvoiceAbi, + functionName: 'getTotalAmountDue', + args: [claimId], + }), + catch: err => + new InvoiceNotFoundError({ + chainId, + claimId, + message: `Failed to read total amount due for claim ${claimId} on chain ${chainId}: ${err}`, + }), + }); + + return { remainingPrincipal: result[0], grossInterest: result[1] }; + }), + + getInvoices: (chainId: ChainId, claimIds: bigint[]) => + Effect.gen(function* () { + const contractAddress = yield* registry.getInvoiceAddress(chainId); + const chain = chainMap[chainId]; + const client = createPublicClient({ chain, transport: http(rpcUrl) }); + + const results = yield* Effect.tryPromise({ + try: () => + client.multicall({ + contracts: claimIds.map(claimId => ({ + address: contractAddress as Hex, + abi: bullaInvoiceAbi, + functionName: 'getInvoice' as const, + args: [claimId] as const, + })), + allowFailure: false, + }), + catch: err => + new InvoiceNotFoundError({ + chainId, + claimId: claimIds[0]!, + message: `Failed to batch-read invoices on chain ${chainId}: ${err}`, + }), + }); + + return results.map(r => ({ + claimAmount: r.claimAmount, + paidAmount: r.paidAmount, + dueBy: r.dueBy, + creditor: r.creditor.toLowerCase() as EthAddress, + debtor: r.debtor.toLowerCase() as EthAddress, + token: r.token.toLowerCase() as EthAddress, + status: r.status, + binding: r.binding, + purchaseOrder: { + deliveryDate: r.purchaseOrder.deliveryDate, + depositAmount: r.purchaseOrder.depositAmount, + isDelivered: r.purchaseOrder.isDelivered, + }, + lateFeeConfig: { + interestRateBps: r.lateFeeConfig.interestRateBps, + numberOfPeriodsPerYear: r.lateFeeConfig.numberOfPeriodsPerYear, + }, + }) satisfies InvoiceOnChain); + }), + + getDepositAmountsNeeded: (chainId: ChainId, claimIds: bigint[]) => + Effect.gen(function* () { + const contractAddress = yield* registry.getInvoiceAddress(chainId); + const chain = chainMap[chainId]; + const client = createPublicClient({ chain, transport: http(rpcUrl) }); + + const results = yield* Effect.tryPromise({ + try: () => + client.multicall({ + contracts: claimIds.map(claimId => ({ + address: contractAddress as Hex, + abi: bullaInvoiceAbi, + functionName: 'getTotalAmountNeededForPurchaseOrderDeposit' as const, + args: [claimId] as const, + })), + allowFailure: false, + }), + catch: err => + new InvoiceNotFoundError({ + chainId, + claimId: claimIds[0]!, + message: `Failed to batch-read deposit amounts on chain ${chainId}: ${err}`, + }), + }); + + return results as bigint[]; + }), + + getTotalAmountsDue: (chainId: ChainId, claimIds: bigint[]) => + Effect.gen(function* () { + const contractAddress = yield* registry.getInvoiceAddress(chainId); + const chain = chainMap[chainId]; + const client = createPublicClient({ chain, transport: http(rpcUrl) }); + + const results = yield* Effect.tryPromise({ + try: () => + client.multicall({ + contracts: claimIds.map(claimId => ({ + address: contractAddress as Hex, + abi: bullaInvoiceAbi, + functionName: 'getTotalAmountDue' as const, + args: [claimId] as const, + })), + allowFailure: false, + }), + catch: err => + new InvoiceNotFoundError({ + chainId, + claimId: claimIds[0]!, + message: `Failed to batch-read total amounts due on chain ${chainId}: ${err}`, + }), + }); + + return results.map(r => ({ + remainingPrincipal: r[0], + grossInterest: r[1], + })); + }), }; }), ); diff --git a/test/e2e/view-functions.e2e.test.ts b/test/e2e/view-functions.e2e.test.ts index ff1e7aa..3af50e6 100644 --- a/test/e2e/view-functions.e2e.test.ts +++ b/test/e2e/view-functions.e2e.test.ts @@ -153,7 +153,7 @@ describe.skipIf(!forkUrl)('view functions (e2e)', () => { 'frendlend', 'get-loan', '--rpc-url', anvil.rpcUrl, '--chain', String(SEPOLIA_CHAIN_ID), - '--claim-id', '9999', + '--claim-ids', '9999', '--format', 'json', ]); expect(result.exitCode).toBeGreaterThanOrEqual(1); @@ -175,7 +175,7 @@ describe.skipIf(!forkUrl)('view functions (e2e)', () => { 'frendlend', 'total-due', '--rpc-url', anvil.rpcUrl, '--chain', String(SEPOLIA_CHAIN_ID), - '--claim-id', '9999', + '--claim-ids', '9999', '--format', 'json', ]); expect(result.exitCode).toBeGreaterThanOrEqual(1); @@ -185,7 +185,7 @@ describe.skipIf(!forkUrl)('view functions (e2e)', () => { const result = runCli([ 'frendlend', 'get-loan', '--rpc-url', anvil.rpcUrl, - '--claim-id', '9999', + '--claim-ids', '9999', '--format', 'json', ]); // Chain should be auto-detected from the RPC, not fail with missing chain error From 5d4c4e8ff3470bacf27afbf9f3a4adc9ee989c16 Mon Sep 17 00:00:00 2001 From: Gerald Bot Date: Wed, 8 Apr 2026 16:53:21 -0400 Subject: [PATCH 2/2] fix: remove redundant getLoan calls from getTotalAmountDue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTotalAmountDue is self-sufficient — no need to fetch the full loan just to validate existence. Also adds a total-due e2e test after accepting a loan offer. Co-Authored-By: Claude Opus 4.6 --- .../reading/viem-frendlend-reader.ts | 71 ++++--------------- test/e2e/frendlend.e2e.test.ts | 25 +++++-- 2 files changed, 35 insertions(+), 61 deletions(-) diff --git a/src/infrastructure/reading/viem-frendlend-reader.ts b/src/infrastructure/reading/viem-frendlend-reader.ts index 7947e0f..4c69261 100644 --- a/src/infrastructure/reading/viem-frendlend-reader.ts +++ b/src/infrastructure/reading/viem-frendlend-reader.ts @@ -145,33 +145,6 @@ export const makeFrendLendReaderLayer = (rpcUrl: string) => transport: http(rpcUrl), }); - // First verify the loan exists (contract returns zeroed data for non-existent claims) - const loan = yield* Effect.tryPromise({ - try: () => - client.readContract({ - address: contractAddress as Hex, - abi: bullaFrendLendV2Abi, - functionName: 'getLoan', - args: [claimId], - }), - catch: err => - new LoanNotFoundError({ - chainId, - claimId, - message: `Failed to read loan ${claimId} on chain ${chainId}: ${err}`, - }), - }); - - if (loan.creditor === zeroAddress && loan.debtor === zeroAddress) { - return yield* Effect.fail( - new LoanNotFoundError({ - chainId, - claimId, - message: `Loan with claim ID ${claimId} does not exist on chain ${chainId}`, - }), - ); - } - const result = yield* Effect.tryPromise({ try: () => client.readContract({ @@ -255,24 +228,17 @@ export const makeFrendLendReaderLayer = (rpcUrl: string) => const chain = chainMap[chainId]; const client = createPublicClient({ chain, transport: http(rpcUrl) }); - // Batch getLoan (validation) + getTotalAmountDue for each claim in one multicall - const contracts = claimIds.flatMap(claimId => [ - { - address: contractAddress as Hex, - abi: bullaFrendLendV2Abi, - functionName: 'getLoan' as const, - args: [claimId] as const, - }, - { - address: contractAddress as Hex, - abi: bullaFrendLendV2Abi, - functionName: 'getTotalAmountDue' as const, - args: [claimId] as const, - }, - ]); - const results = yield* Effect.tryPromise({ - try: () => client.multicall({ contracts, allowFailure: false }), + try: () => + client.multicall({ + contracts: claimIds.map(claimId => ({ + address: contractAddress as Hex, + abi: bullaFrendLendV2Abi, + functionName: 'getTotalAmountDue' as const, + args: [claimId] as const, + })), + allowFailure: false, + }), catch: err => new LoanNotFoundError({ chainId, @@ -281,19 +247,10 @@ export const makeFrendLendReaderLayer = (rpcUrl: string) => }), }); - // Results interleaved: [loan0, due0, loan1, due1, ...] - return claimIds.map((claimId, i) => { - const loan = results[i * 2] as { creditor: string; debtor: string }; - if (loan.creditor === zeroAddress && loan.debtor === zeroAddress) { - throw new LoanNotFoundError({ - chainId, - claimId, - message: `Loan with claim ID ${claimId} does not exist on chain ${chainId}`, - }); - } - const due = results[i * 2 + 1] as readonly [bigint, bigint]; - return { remainingPrincipal: due[0], grossInterest: due[1] }; - }); + return results.map(r => ({ + remainingPrincipal: r[0], + grossInterest: r[1], + })); }), }; }), diff --git a/test/e2e/frendlend.e2e.test.ts b/test/e2e/frendlend.e2e.test.ts index 3423225..94fc25c 100644 --- a/test/e2e/frendlend.e2e.test.ts +++ b/test/e2e/frendlend.e2e.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { runCliExecute } from './helpers/cli-runner.js'; +import { runCli, runCliExecute } from './helpers/cli-runner.js'; import { approveCreateClaim, WETH_ADDRESS, wrapEthAndApprove } from './helpers/erc20-setup.js'; -import { getOfferIdFromReceipt } from './helpers/receipt-parser.js'; +import { getNewTokenIdFromReceipt, getOfferIdFromReceipt } from './helpers/receipt-parser.js'; import { type AnvilInstance, startAnvil } from './setup/anvil.js'; import { ANVIL_ACCOUNTS, CONTRACTS, SEPOLIA_CHAIN_ID } from './setup/constants.js'; @@ -119,8 +119,9 @@ describe.skipIf(!forkUrl)('bulla frendlend (e2e)', () => { }); }); - describe('loan lifecycle: offer -> accept', () => { + describe('loan lifecycle: offer -> accept -> total-due', () => { let offerId: bigint; + let claimId: bigint; it('creates a loan offer', async () => { const result = runCliExecute([ @@ -155,7 +156,7 @@ describe.skipIf(!forkUrl)('bulla frendlend (e2e)', () => { offerId = await getOfferIdFromReceipt(anvil.rpcUrl, result.txHash as `0x${string}`); }); - it('accepts the loan offer', () => { + it('accepts the loan offer', async () => { const result = runCliExecute([ 'frendlend', 'accept-loan', @@ -171,6 +172,22 @@ describe.skipIf(!forkUrl)('bulla frendlend (e2e)', () => { ]); expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + claimId = await getNewTokenIdFromReceipt(anvil.rpcUrl, result.txHash as `0x${string}`); + expect(claimId).toBeGreaterThan(0n); + }); + + it('returns total amount due for the accepted loan', () => { + const result = runCli([ + 'frendlend', 'total-due', + '--rpc-url', anvil.rpcUrl, + '--chain', String(SEPOLIA_CHAIN_ID), + '--claim-ids', String(claimId), + '--format', 'json', + ]); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('remainingPrincipal'); + expect(parsed).toHaveProperty('grossInterest'); }); }); });