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
2 changes: 2 additions & 0 deletions src/application/ports/backend-client-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface BackendClientService {
chainId: number,
poolAddress: string,
body: UnderwriteRequest,
isSafe?: boolean,
) => Effect.Effect<UnderwriteResponse, Error>;

/** POST /tapCredit/batch/{wallet}/chain/{chainId}/pool/{poolAddress} — batch tap-credit. */
Expand All @@ -35,6 +36,7 @@ export interface BackendClientService {
chainId: number,
poolAddress: string,
body: TapCreditRequest,
isSafe?: boolean,
) => Effect.Effect<TapCreditResponse, Error>;
}

Expand Down
22 changes: 14 additions & 8 deletions src/cli/factoring/backend-commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from '@effect/cli';
import { readFileSync } from 'node:fs';
import { Console, Effect } from 'effect';
import { Console, Effect, Option } from 'effect';
import { privateKeyToAccount } from 'viem/accounts';
import { BackendClientService } from '../../application/ports/backend-client-port.js';
import type { TapCreditRequestItem } from '../../domain/types/backend.js';
Expand All @@ -10,7 +10,7 @@ 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 { authTokenOption, claimIdsOption, poolAddressOption, requestsFileOption, safeAddressOption } from '../options/factoring-options.js';
import { privateKeyOption } from '../options/pay-options.js';

// ============================================================================
Expand Down Expand Up @@ -75,18 +75,21 @@ const underwriteCommand = Command.make(
chain: chainOption,
claimIds: claimIdsOption,
format: formatOption,
safeAddress: safeAddressOption,
},
({ authToken, poolAddress, chain, claimIds, format }) =>
({ authToken, poolAddress, chain, claimIds, format, safeAddress }) =>
Effect.gen(function* () {
const wallet = extractWalletFromJwt(authToken);
const jwtWallet = extractWalletFromJwt(authToken);
const isSafe = Option.isSome(safeAddress);
const wallet = Option.getOrElse(safeAddress, () => jwtWallet);
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,
});
}, isSafe);

for (const result of response.results) {
yield* Console.log(
Expand Down Expand Up @@ -116,10 +119,13 @@ const tapCreditCommand = Command.make(
chain: chainOption,
requestsFile: requestsFileOption,
format: formatOption,
safeAddress: safeAddressOption,
},
({ authToken, poolAddress, chain, requestsFile, format }) =>
({ authToken, poolAddress, chain, requestsFile, format, safeAddress }) =>
Effect.gen(function* () {
const wallet = extractWalletFromJwt(authToken);
const jwtWallet = extractWalletFromJwt(authToken);
const isSafe = Option.isSome(safeAddress);
const wallet = Option.getOrElse(safeAddress, () => jwtWallet);
const chainId = yield* getChainId(chain, undefined);

// Read and parse the requests file
Expand All @@ -135,7 +141,7 @@ const tapCreditCommand = Command.make(

const client = yield* BackendClientService;

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

for (const result of response.results) {
yield* Console.log(
Expand Down
5 changes: 5 additions & 0 deletions src/cli/options/factoring-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,8 @@ export const claimIdsOption = Options.text('claim-ids').pipe(
export const requestsFileOption = Options.text('requests-file').pipe(
Options.withDescription('Path to JSON file containing tap-credit requests array'),
);

export const safeAddressOption = Options.text('safe-address').pipe(
Options.withDescription('Safe multisig address to act on behalf of (JWT signer must be a signer of the safe)'),
Options.optional,
);
32 changes: 20 additions & 12 deletions src/infrastructure/http/backend-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,26 @@ export const BackendClientLive = Layer.succeed(BackendClientService, {
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',
underwrite: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: UnderwriteRequest, isSafe?: boolean) => {
const params = new URLSearchParams({ isV2: 'true' });
if (isSafe) params.set('account_type', 'gnosis');
return fetchJson<UnderwriteResponse>(
`${UNDERWRITER_BASE_URL}/underwrite/${wallet}/chain/${chainId}/pool/${poolAddress}?${params}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
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}`,
tapCredit: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: TapCreditRequest, isSafe?: boolean) => {
const params = isSafe ? '?account_type=gnosis' : '';
return fetchJson<TapCreditResponse>(
`${UNDERWRITER_BASE_URL}/tapCredit/batch/${wallet}/chain/${chainId}/pool/${poolAddress}${params}`,
{
method: 'POST',
headers: {
Expand All @@ -57,5 +64,6 @@ export const BackendClientLive = Layer.succeed(BackendClientService, {
},
body: JSON.stringify(body),
},
),
);
},
});
57 changes: 53 additions & 4 deletions test/cli/factoring/backend-commands.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ const SIWE_MESSAGE = [
// Mock HTTP server
// ============================================================================

const TEST_SAFE = '0x1234567890abcdef1234567890abcdef12345678';

let mockServer: Server;
let mockPort: number;
let tmpDir: string;
let lastRequestedUrl: string;

/** Read the full request body as a string. */
const readBody = (req: IncomingMessage): Promise<string> =>
Expand All @@ -53,16 +56,18 @@ const readBody = (req: IncomingMessage): Promise<string> =>
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
const url = req.url ?? '';
const method = req.method ?? 'GET';
lastRequestedUrl = url;
const pathname = url.split('?')[0];

// GET /message/{wallet}
if (method === 'GET' && url.match(/^\/message\/0x[0-9a-fA-F]+$/)) {
if (method === 'GET' && pathname.match(/^\/message\/0x[0-9a-fA-F]+$/)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: SIWE_MESSAGE }));
return;
}

// POST /verify/{wallet}
if (method === 'POST' && url.match(/^\/verify\/0x[0-9a-fA-F]+$/)) {
if (method === 'POST' && pathname.match(/^\/verify\/0x[0-9a-fA-F]+$/)) {
const signature = await readBody(req);
const walletMatch = url.match(/\/verify\/(0x[0-9a-fA-F]+)$/);
const wallet = walletMatch?.[1] ?? '';
Expand Down Expand Up @@ -93,7 +98,7 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
}

// POST /underwrite/{wallet}/chain/{chainId}/pool/{poolAddress}
if (method === 'POST' && url.match(/^\/underwrite\/0x[0-9a-fA-F]+\/chain\/\d+\/pool\/0x[0-9a-fA-F]+$/)) {
if (method === 'POST' && pathname.match(/^\/underwrite\/0x[0-9a-fA-F]+\/chain\/\d+\/pool\/0x[0-9a-fA-F]+$/)) {
const body = JSON.parse(await readBody(req)) as { claimIds: string[] };
const results = body.claimIds.map((claimId) => ({
claimId,
Expand All @@ -107,7 +112,7 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
}

// POST /tapCredit/batch/{wallet}/chain/{chainId}/pool/{poolAddress}
if (method === 'POST' && url.match(/^\/tapCredit\/batch\/0x[0-9a-fA-F]+\/chain\/\d+\/pool\/0x[0-9a-fA-F]+$/)) {
if (method === 'POST' && pathname.match(/^\/tapCredit\/batch\/0x[0-9a-fA-F]+\/chain\/\d+\/pool\/0x[0-9a-fA-F]+$/)) {
const body = JSON.parse(await readBody(req)) as { requests: unknown[] };
const results = body.requests.map((_, index) => ({
index,
Expand Down Expand Up @@ -249,6 +254,25 @@ describe('factoring underwrite', () => {
expect(output).toContain('"status": "Ok"');
expect(output).toContain('"txHash"');
});

it('uses safe address in URL and adds account_type=gnosis when --safe-address is provided', async () => {
const result = await runCliAsync(
[
'factoring', 'underwrite',
'--auth-token', FAKE_JWT,
'--pool-address', TEST_POOL,
'--chain', TEST_CHAIN,
'--claim-ids', '1',
'--safe-address', TEST_SAFE,
'--format', 'json',
],
mockEnv(),
);
expect(result.exitCode).toBe(0);
expect(lastRequestedUrl).toContain(`/underwrite/${TEST_SAFE}/`);
expect(lastRequestedUrl).not.toContain(TEST_WALLET);
expect(lastRequestedUrl).toContain('account_type=gnosis');
});
});

// ============================================================================
Expand Down Expand Up @@ -320,4 +344,29 @@ describe('factoring tap-credit', () => {
expect(output).toContain('"status": "Ok"');
expect(output).toContain('"txHash"');
});

it('uses safe address in URL and adds account_type=gnosis when --safe-address is provided', async () => {
const requestsFile = join(tmpDir, 'requests-safe.json');
writeFileSync(
requestsFile,
JSON.stringify([{ description: 'Safe Invoice', dueBy: 1700000000, amount: '500000' }]),
);

const result = await runCliAsync(
[
'factoring', 'tap-credit',
'--auth-token', FAKE_JWT,
'--pool-address', TEST_POOL,
'--chain', TEST_CHAIN,
'--requests-file', requestsFile,
'--safe-address', TEST_SAFE,
'--format', 'json',
],
mockEnv(),
);
expect(result.exitCode).toBe(0);
expect(lastRequestedUrl).toContain(`/tapCredit/batch/${TEST_SAFE}/`);
expect(lastRequestedUrl).not.toContain(TEST_WALLET);
expect(lastRequestedUrl).toContain('account_type=gnosis');
});
});
Loading