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
7 changes: 7 additions & 0 deletions src/application/ports/factoring-reader-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export interface FactoringReaderService {
readonly getQueueStats: (poolAddress: EthAddress) => Effect.Effect<QueueStats, Error>;
readonly getNextRedemption: (poolAddress: EthAddress) => Effect.Effect<QueuedRedemption, Error>;
readonly isQueueEmpty: (poolAddress: EthAddress) => Effect.Effect<boolean, Error>;
readonly pricePerShare: (poolAddress: EthAddress) => Effect.Effect<bigint, Error>;
readonly balanceOf: (poolAddress: EthAddress, account: EthAddress) => Effect.Effect<bigint, Error>;
readonly totalAssets: (poolAddress: EthAddress) => Effect.Effect<bigint, Error>;
readonly totalSupply: (poolAddress: EthAddress) => Effect.Effect<bigint, Error>;
readonly activeInvoiceAt: (poolAddress: EthAddress, index: bigint) => Effect.Effect<bigint, Error>;
readonly maxRedeem: (poolAddress: EthAddress, owner: EthAddress) => Effect.Effect<bigint, Error>;
readonly paidInvoicesGain: (poolAddress: EthAddress) => Effect.Effect<bigint, Error>;
}

export const FactoringReaderService = Context.GenericTag<FactoringReaderService>('@services/FactoringReaderService');
175 changes: 175 additions & 0 deletions src/cli/factoring/view-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -345,4 +513,11 @@ export const factoringViewCommands = [
targetFeesCommand,
capitalCommand,
accruedProfitsCommand,
priceCommand,
balanceCommand,
totalAssetsCommand,
totalSupplyCommand,
activeInvoicesCommand,
maxRedeemCommand,
paidInvoicesGainCommand,
] as const;
7 changes: 7 additions & 0 deletions src/cli/options/factoring-options.ts
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down Expand Up @@ -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'));
Expand Down
15 changes: 10 additions & 5 deletions src/domain/types/factoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 103 additions & 4 deletions src/infrastructure/reading/viem-factoring-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
),

Expand Down Expand Up @@ -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}`),
}),
});
9 changes: 7 additions & 2 deletions test/e2e/view-functions.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading