diff --git a/src/application/ports/factoring-reader-port.ts b/src/application/ports/factoring-reader-port.ts index 423abc8..f051ff2 100644 --- a/src/application/ports/factoring-reader-port.ts +++ b/src/application/ports/factoring-reader-port.ts @@ -21,6 +21,13 @@ export interface FactoringReaderService { readonly getQueueStats: (poolAddress: EthAddress) => Effect.Effect; readonly getNextRedemption: (poolAddress: EthAddress) => Effect.Effect; readonly isQueueEmpty: (poolAddress: EthAddress) => Effect.Effect; + readonly pricePerShare: (poolAddress: EthAddress) => Effect.Effect; + readonly balanceOf: (poolAddress: EthAddress, account: EthAddress) => Effect.Effect; + readonly totalAssets: (poolAddress: EthAddress) => Effect.Effect; + readonly totalSupply: (poolAddress: EthAddress) => Effect.Effect; + readonly activeInvoiceAt: (poolAddress: EthAddress, index: bigint) => Effect.Effect; + readonly maxRedeem: (poolAddress: EthAddress, owner: EthAddress) => Effect.Effect; + readonly paidInvoicesGain: (poolAddress: EthAddress) => Effect.Effect; } export const FactoringReaderService = Context.GenericTag('@services/FactoringReaderService'); diff --git a/src/cli/factoring/view-commands.ts b/src/cli/factoring/view-commands.ts index 33cd904..29f4993 100644 --- a/src/cli/factoring/view-commands.ts +++ b/src/cli/factoring/view-commands.ts @@ -7,8 +7,10 @@ import type { OutputFormat } from '../formatters/index.js'; import { formatViewResult } from '../formatters/view.js'; import { formatOption, requiredRpcUrlOption } from '../options/common.js'; import { + accountOption, assetsOption, factoringInvoiceIdOption, + invoiceIndexOption, poolAddressOption, sharesOption, upfrontBpsOption, @@ -261,6 +263,172 @@ const accruedProfitsCommand = Command.make( }), ).pipe(Command.withDescription('Calculate accrued profits for a factoring pool')); +// ============================================================================ +// PRICE +// ============================================================================ + +const priceCommand = Command.make( + 'price', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.pricePerShare(pool); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ pricePerShare: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View the current price per share')); + +// ============================================================================ +// BALANCE +// ============================================================================ + +const balanceCommand = Command.make( + 'balance', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + account: accountOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, account, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const accountAddr = toPoolAddress(account); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.balanceOf(pool, accountAddr); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ balance: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View the share balance for an investor')); + +// ============================================================================ +// TOTAL-ASSETS +// ============================================================================ + +const totalAssetsCommand = Command.make( + 'total-assets', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.totalAssets(pool); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ totalAssets: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View the total assets in the pool')); + +// ============================================================================ +// TOTAL-SUPPLY +// ============================================================================ + +const totalSupplyCommand = Command.make( + 'total-supply', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.totalSupply(pool); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ totalSupply: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View the total shares outstanding')); + +// ============================================================================ +// ACTIVE-INVOICES +// ============================================================================ + +const activeInvoicesCommand = Command.make( + 'active-invoices', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + index: invoiceIndexOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, index, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.activeInvoiceAt(pool, BigInt(index)); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ invoiceId: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View the active invoice ID at a given index')); + +// ============================================================================ +// MAX-REDEEM +// ============================================================================ + +const maxRedeemCommand = Command.make( + 'max-redeem', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + account: accountOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, account, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const ownerAddr = toPoolAddress(account); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.maxRedeem(pool, ownerAddr); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ maxRedeem: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View the maximum redeemable shares for an owner')); + +// ============================================================================ +// PAID-INVOICES-GAIN +// ============================================================================ + +const paidInvoicesGainCommand = Command.make( + 'paid-invoices-gain', + { + rpcUrl: requiredRpcUrlOption, + poolAddress: poolAddressOption, + format: formatOption, + }, + ({ rpcUrl, poolAddress, format }) => + Effect.gen(function* () { + const pool = toPoolAddress(poolAddress); + const readerLayer = makeFactoringReaderLayer(rpcUrl); + const result = yield* Effect.gen(function* () { + const reader = yield* FactoringReaderService; + return yield* reader.paidInvoicesGain(pool); + }).pipe(Effect.provide(readerLayer)); + yield* Console.log(formatViewResult({ paidInvoicesGain: result }, format as OutputFormat)); + }), +).pipe(Command.withDescription('View total realized gains from paid invoices')); + // ============================================================================ // QUEUE SUBCOMMANDS // ============================================================================ @@ -345,4 +513,11 @@ export const factoringViewCommands = [ targetFeesCommand, capitalCommand, accruedProfitsCommand, + priceCommand, + balanceCommand, + totalAssetsCommand, + totalSupplyCommand, + activeInvoicesCommand, + maxRedeemCommand, + paidInvoicesGainCommand, ] as const; diff --git a/src/cli/options/factoring-options.ts b/src/cli/options/factoring-options.ts index 2663182..0a64bac 100644 --- a/src/cli/options/factoring-options.ts +++ b/src/cli/options/factoring-options.ts @@ -1,5 +1,10 @@ import { Options } from '@effect/cli'; +export const accountOption = Options.text('account').pipe( + Options.withAlias('a'), + Options.withDescription('Account address (e.g. investor or owner address)'), +); + export const poolAddressOption = Options.text('pool-address').pipe( Options.withAlias('p'), Options.withDescription('Factoring pool contract address'), @@ -30,6 +35,8 @@ export const invoiceValueOverrideOption = Options.text('invoice-value-override') Options.withDescription('Initial invoice value override (0 = use actual value)'), ); +export const invoiceIndexOption = Options.text('index').pipe(Options.withDescription('Index into the active invoices array (uint256)')); + export const principalAmountOption = Options.text('principal-amount').pipe(Options.withDescription('Loan principal amount in token wei')); export const poolTermLengthOption = Options.integer('term-length').pipe(Options.withDescription('Loan term length in seconds')); diff --git a/src/domain/types/factoring.ts b/src/domain/types/factoring.ts index 09ea5e3..082fb80 100644 --- a/src/domain/types/factoring.ts +++ b/src/domain/types/factoring.ts @@ -67,12 +67,17 @@ export interface CancelQueuedRedemptionParams { // -- View function result types -- -// Result of getFundInfo() +// Result of getFundInfo() — maps all 9 fields of the on-chain FundInfo struct export interface FundInfo { - totalAssets: bigint; - totalSupply: bigint; - adminFeeBalance: bigint; - protocolFeeBalance: bigint; + name: string; + creationTimestamp: bigint; + fundBalance: bigint; + deployedCapital: bigint; + capitalAccount: bigint; + price: bigint; + tokensAvailableForRedemption: bigint; + adminFeeBps: number; + targetYieldBps: bigint; } // Result of viewPoolStatus(offset, limit) diff --git a/src/infrastructure/reading/viem-factoring-reader.ts b/src/infrastructure/reading/viem-factoring-reader.ts index 8f72d81..08ec3f4 100644 --- a/src/infrastructure/reading/viem-factoring-reader.ts +++ b/src/infrastructure/reading/viem-factoring-reader.ts @@ -54,10 +54,15 @@ export const makeFactoringReaderLayer = (rpcUrl: string) => catch: err => new Error(`Failed to read fund info from pool ${poolAddress}: ${err}`), }).pipe( Effect.map((result): FundInfo => ({ - totalAssets: result.fundBalance, - totalSupply: result.capitalAccount, - adminFeeBalance: BigInt(result.adminFeeBps), - protocolFeeBalance: result.deployedCapital, + name: result.name, + creationTimestamp: result.creationTimestamp, + fundBalance: result.fundBalance, + deployedCapital: result.deployedCapital, + capitalAccount: result.capitalAccount, + price: result.price, + tokensAvailableForRedemption: result.tokensAvailableForRedemption, + adminFeeBps: result.adminFeeBps, + targetYieldBps: result.targetYieldBps, })), ), @@ -289,4 +294,98 @@ export const makeFactoringReaderLayer = (rpcUrl: string) => }), ), ), + + pricePerShare: (poolAddress: EthAddress) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'pricePerShare', + }); + }, + catch: err => new Error(`Failed to read price per share from pool ${poolAddress}: ${err}`), + }), + + balanceOf: (poolAddress: EthAddress, account: EthAddress) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'balanceOf', + args: [account as Hex], + }); + }, + catch: err => new Error(`Failed to read balance for ${account} from pool ${poolAddress}: ${err}`), + }), + + totalAssets: (poolAddress: EthAddress) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'totalAssets', + }); + }, + catch: err => new Error(`Failed to read total assets from pool ${poolAddress}: ${err}`), + }), + + totalSupply: (poolAddress: EthAddress) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'totalSupply', + }); + }, + catch: err => new Error(`Failed to read total supply from pool ${poolAddress}: ${err}`), + }), + + activeInvoiceAt: (poolAddress: EthAddress, index: bigint) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'activeInvoices', + args: [index], + }); + }, + catch: err => new Error(`Failed to read active invoice at index ${index} from pool ${poolAddress}: ${err}`), + }), + + maxRedeem: (poolAddress: EthAddress, owner: EthAddress) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'maxRedeem', + args: [owner as Hex], + }); + }, + catch: err => new Error(`Failed to read max redeem for ${owner} from pool ${poolAddress}: ${err}`), + }), + + paidInvoicesGain: (poolAddress: EthAddress) => + Effect.tryPromise({ + try: () => { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: poolAddress as Hex, + abi: bullaFactoringV2_1Abi, + functionName: 'paidInvoicesGain', + }); + }, + catch: err => new Error(`Failed to read paid invoices gain from pool ${poolAddress}: ${err}`), + }), }); diff --git a/test/e2e/view-functions.e2e.test.ts b/test/e2e/view-functions.e2e.test.ts index 32df901..ff1e7aa 100644 --- a/test/e2e/view-functions.e2e.test.ts +++ b/test/e2e/view-functions.e2e.test.ts @@ -56,8 +56,13 @@ describe.skipIf(!forkUrl)('view functions (e2e)', () => { ]); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty('totalAssets'); - expect(parsed).toHaveProperty('totalSupply'); + expect(parsed).toHaveProperty('name'); + expect(parsed).toHaveProperty('fundBalance'); + expect(parsed).toHaveProperty('deployedCapital'); + expect(parsed).toHaveProperty('capitalAccount'); + expect(parsed).toHaveProperty('price'); + expect(parsed).toHaveProperty('adminFeeBps'); + expect(parsed).toHaveProperty('targetYieldBps'); }); it('preview-deposit returns shares amount', () => {