Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/application/ports/frendlend-reader-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoanOnChain[], LoanNotFoundError | UnsupportedChainError | ContractNotFoundError>;

getTotalAmountsDue(
chainId: ChainId,
claimIds: bigint[],
): Effect.Effect<
{ remainingPrincipal: bigint; grossInterest: bigint }[],
LoanNotFoundError | UnsupportedChainError | ContractNotFoundError
>;
}

export const FrendLendReaderService = Context.GenericTag<FrendLendReaderService>('@services/FrendLendReaderService');
26 changes: 26 additions & 0 deletions src/application/ports/invoice-reader-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,32 @@ export interface InvoiceReaderService {
chainId: ChainId,
claimId: bigint,
): Effect.Effect<bigint, InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError>;

getTotalAmountDue(
chainId: ChainId,
claimId: bigint,
): Effect.Effect<
{ remainingPrincipal: bigint; grossInterest: bigint },
InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError
>;

getInvoices(
chainId: ChainId,
claimIds: bigint[],
): Effect.Effect<InvoiceOnChain[], InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError>;

getDepositAmountsNeeded(
chainId: ChainId,
claimIds: bigint[],
): Effect.Effect<bigint[], InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError>;

getTotalAmountsDue(
chainId: ChainId,
claimIds: bigint[],
): Effect.Effect<
{ remainingPrincipal: bigint; grossInterest: bigint }[],
InvoiceNotFoundError | UnsupportedChainError | ContractNotFoundError
>;
}

export const InvoiceReaderService = Context.GenericTag<InvoiceReaderService>('@services/InvoiceReaderService');
5 changes: 5 additions & 0 deletions src/cli/formatters/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ export const formatViewResult = (data: Record<string, unknown>, format: OutputFo
}
return lines.join('\n');
};

export const formatViewResults = (data: Record<string, unknown>[], 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');
};
41 changes: 30 additions & 11 deletions src/cli/frendlend/view-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ============================================================================
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
// ============================================================================
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 58 additions & 15 deletions src/cli/invoice/view-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown>, format as OutputFormat));
const data = results.map((r, i) => ({ claimId: ids[i]!.toString(), ...r }) as unknown as Record<string, unknown>);
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
Expand All @@ -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;
6 changes: 6 additions & 0 deletions src/cli/options/invoice-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'));
Expand Down
10 changes: 10 additions & 0 deletions src/infrastructure/abi/bulla-invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading