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
41 changes: 41 additions & 0 deletions src/application/ports/backend-client-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Context, Effect } from 'effect';
import type {
GetMessageResponse,
TapCreditRequest,
TapCreditResponse,
UnderwriteRequest,
UnderwriteResponse,
VerifyMessageResponse,
} from '../../domain/types/backend.js';

/**
* Port for interacting with the Bulla backend API.
* Covers authentication, underwriting, and tap-credit flows.
*/
export interface BackendClientService {
/** GET /auth/{wallet}/getMessage — fetch SIWE challenge message. */
readonly getMessage: (wallet: string) => Effect.Effect<GetMessageResponse, Error>;

/** POST /auth/{wallet}/verifyMessage — submit signed message and receive JWT. */
readonly verifyMessage: (wallet: string, signature: string) => Effect.Effect<VerifyMessageResponse, Error>;

/** POST /underwrite/{wallet}/chain/{chainId}/pool/{poolAddress} — underwrite claims. */
readonly underwrite: (
authToken: string,
wallet: string,
chainId: number,
poolAddress: string,
body: UnderwriteRequest,
) => Effect.Effect<UnderwriteResponse, Error>;

/** POST /tapCredit/batch/{wallet}/chain/{chainId}/pool/{poolAddress} — batch tap-credit. */
readonly tapCredit: (
authToken: string,
wallet: string,
chainId: number,
poolAddress: string,
body: TapCreditRequest,
) => Effect.Effect<TapCreditResponse, Error>;
}

export const BackendClientService = Context.GenericTag<BackendClientService>('@services/BackendClientService');
3 changes: 2 additions & 1 deletion src/cli/commands/factoring.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Command } from '@effect/cli';
import { backendCommands } from '../factoring/backend-commands.js';
import { factoringCommands } from '../factoring/commands.js';
import { factoringViewCommands, queueCommand } from '../factoring/view-commands.js';

export const factoringCommand = Command.make('factoring', {}).pipe(
Command.withDescription('Factoring pool operations'),
Command.withSubcommands([...factoringCommands, ...factoringViewCommands, queueCommand]),
Command.withSubcommands([...factoringCommands, ...factoringViewCommands, ...backendCommands, queueCommand]),
);
160 changes: 160 additions & 0 deletions src/cli/factoring/backend-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Command } from '@effect/cli';
import { readFileSync } from 'node:fs';
import { Console, Effect } from 'effect';
import { privateKeyToAccount } from 'viem/accounts';
import { BackendClientService } from '../../application/ports/backend-client-port.js';
import type { TapCreditRequestItem } from '../../domain/types/backend.js';
import type { Hex } from '../../domain/types/eth.js';
import { BackendClientLive } from '../../infrastructure/http/backend-client.js';
import type { OutputFormat } from '../formatters/index.js';
import { formatViewResult } from '../formatters/view.js';
import { chainOption, formatOption } from '../options/common.js';
import { getChainId } from '../options/common.js';
import { authTokenOption, claimIdsOption, poolAddressOption, requestsFileOption } from '../options/factoring-options.js';
import { privateKeyOption } from '../options/pay-options.js';

// ============================================================================
// HELPERS
// ============================================================================

/** Extract wallet address from a JWT token by decoding the base64 payload. */
const extractWalletFromJwt = (token: string): string => {
const parts = token.split('.');
const payload = parts[1];
if (!payload) {
throw new Error('Invalid JWT: missing payload segment');
}
const decoded = JSON.parse(Buffer.from(payload, 'base64').toString()) as { wallet?: string };
if (!decoded.wallet) {
throw new Error('Invalid JWT: missing wallet field in payload');
}
return decoded.wallet;
};

// ============================================================================
// AUTHENTICATE
// ============================================================================

const authenticateCommand = Command.make(
'authenticate',
{
privateKey: privateKeyOption,
},
({ privateKey }) =>
Effect.gen(function* () {
const account = privateKeyToAccount(privateKey as Hex);
const wallet = account.address;

const client = yield* BackendClientService;

// Step 1: Get SIWE challenge message
const { message } = yield* client.getMessage(wallet);

// Step 2: Sign the message
const signature = yield* Effect.tryPromise({
try: () => account.signMessage({ message }),
catch: (err) => new Error(`Failed to sign message: ${err instanceof Error ? err.message : String(err)}`),
});

// Step 3: Verify and get JWT
const { message: token } = yield* client.verifyMessage(wallet, signature);

yield* Console.log(token);
}).pipe(Effect.provide(BackendClientLive)),
).pipe(Command.withDescription('Authenticate with the Bulla backend and obtain a JWT token'));

// ============================================================================
// UNDERWRITE
// ============================================================================

const underwriteCommand = Command.make(
'underwrite',
{
authToken: authTokenOption,
poolAddress: poolAddressOption,
chain: chainOption,
claimIds: claimIdsOption,
format: formatOption,
},
({ authToken, poolAddress, chain, claimIds, format }) =>
Effect.gen(function* () {
const wallet = extractWalletFromJwt(authToken);
const chainId = yield* getChainId(chain, undefined);

const client = yield* BackendClientService;
const claimIdList = claimIds.split(',').map(id => id.trim());

const response = yield* client.underwrite(authToken, wallet, chainId, poolAddress, {
claimIds: claimIdList,
});

for (const result of response.results) {
yield* Console.log(
formatViewResult(
{
claimId: result.claimId,
status: result.status,
txHash: result.txHash,
errors: result.errors.length > 0 ? result.errors.join(', ') : 'none',
},
format as OutputFormat,
),
);
}
}).pipe(Effect.provide(BackendClientLive)),
).pipe(Command.withDescription('Underwrite claims via the Bulla backend'));

// ============================================================================
// TAP-CREDIT
// ============================================================================

const tapCreditCommand = Command.make(
'tap-credit',
{
authToken: authTokenOption,
poolAddress: poolAddressOption,
chain: chainOption,
requestsFile: requestsFileOption,
format: formatOption,
},
({ authToken, poolAddress, chain, requestsFile, format }) =>
Effect.gen(function* () {
const wallet = extractWalletFromJwt(authToken);
const chainId = yield* getChainId(chain, undefined);

// Read and parse the requests file
const fileContent = yield* Effect.try({
try: () => readFileSync(requestsFile, 'utf-8'),
catch: (err) => new Error(`Failed to read requests file: ${err instanceof Error ? err.message : String(err)}`),
});

const requests = yield* Effect.try({
try: () => JSON.parse(fileContent) as readonly TapCreditRequestItem[],
catch: (err) => new Error(`Failed to parse requests file: ${err instanceof Error ? err.message : String(err)}`),
});

const client = yield* BackendClientService;

const response = yield* client.tapCredit(authToken, wallet, chainId, poolAddress, { requests });

for (const result of response.results) {
yield* Console.log(
formatViewResult(
{
index: result.index,
status: result.status,
txHash: result.txHash,
errors: result.errors.length > 0 ? result.errors.join(', ') : 'none',
},
format as OutputFormat,
),
);
}
}).pipe(Effect.provide(BackendClientLive)),
).pipe(Command.withDescription('Batch tap-credit requests via the Bulla backend'));

// ============================================================================
// EXPORT
// ============================================================================

export const backendCommands = [authenticateCommand, underwriteCommand, tapCreditCommand] as const;
12 changes: 12 additions & 0 deletions src/cli/options/factoring-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,15 @@ export const poolTermLengthOption = Options.integer('term-length').pipe(Options.
export const periodsPerYearOption = Options.integer('periods-per-year').pipe(
Options.withDescription('Number of interest periods per year'),
);

export const authTokenOption = Options.text('auth-token').pipe(
Options.withDescription('JWT authentication token from the authenticate command'),
);

export const claimIdsOption = Options.text('claim-ids').pipe(
Options.withDescription('Comma-separated list of claim IDs to underwrite'),
);

export const requestsFileOption = Options.text('requests-file').pipe(
Options.withDescription('Path to JSON file containing tap-credit requests array'),
);
55 changes: 55 additions & 0 deletions src/domain/types/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// ============================================================================
// Auth types
// ============================================================================

export interface GetMessageResponse {
readonly message: string;
}

export interface VerifyMessageResponse {
readonly message: string;
}

// ============================================================================
// Underwrite types
// ============================================================================

export interface UnderwriteRequest {
readonly claimIds: readonly string[];
}

export interface UnderwriteResultItem {
readonly claimId: string;
readonly status: string;
readonly txHash: string;
readonly errors: readonly string[];
}

export interface UnderwriteResponse {
readonly results: readonly UnderwriteResultItem[];
}

// ============================================================================
// Tap-credit types
// ============================================================================

export interface TapCreditRequestItem {
readonly description: string;
readonly dueBy: number;
readonly amount: string;
}

export interface TapCreditRequest {
readonly requests: readonly TapCreditRequestItem[];
}

export interface TapCreditResultItem {
readonly index: number;
readonly status: string;
readonly txHash: string;
readonly errors: readonly string[];
}

export interface TapCreditResponse {
readonly results: readonly TapCreditResultItem[];
}
61 changes: 61 additions & 0 deletions src/infrastructure/http/backend-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Effect, Layer } from 'effect';
import { BackendClientService } from '../../application/ports/backend-client-port.js';
import type {
GetMessageResponse,
TapCreditRequest,
TapCreditResponse,
UnderwriteRequest,
UnderwriteResponse,
VerifyMessageResponse,
} from '../../domain/types/backend.js';

const AUTH_BASE_URL = process.env.BULLA_AUTH_URL ?? 'https://apiauth.bulla.network';
const UNDERWRITER_BASE_URL = process.env.BULLA_UW_URL ?? 'https://apiuw.bulla.network';

const fetchJson = <T>(url: string, init?: RequestInit): Effect.Effect<T, Error> =>
Effect.tryPromise({
try: async () => {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status} from ${url}: ${body}`);
}
return (await res.json()) as T;
},
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
});

export const BackendClientLive = Layer.succeed(BackendClientService, {
getMessage: (wallet: string) =>
fetchJson<GetMessageResponse>(`${AUTH_BASE_URL}/auth/${wallet}/getMessage`),

verifyMessage: (wallet: string, signature: string) =>
fetchJson<VerifyMessageResponse>(`${AUTH_BASE_URL}/auth/${wallet}/verifyMessage`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: signature,
}),

underwrite: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: UnderwriteRequest) =>
fetchJson<UnderwriteResponse>(`${UNDERWRITER_BASE_URL}/underwrite/${wallet}/chain/${chainId}/pool/${poolAddress}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}),

tapCredit: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: TapCreditRequest) =>
fetchJson<TapCreditResponse>(
`${UNDERWRITER_BASE_URL}/tapCredit/batch/${wallet}/chain/${chainId}/pool/${poolAddress}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
),
});
Loading
Loading