From ed917239b6d332c00c808d570fe8aa1cf027c67d Mon Sep 17 00:00:00 2001 From: Benjamin Gobeil Date: Thu, 19 Mar 2026 09:40:01 -0400 Subject: [PATCH] feat: add --safe-address option and isV2 query param for underwrite/tap-credit - Add optional --safe-address flag to underwrite and tap-credit commands - When provided, use safe address in URL instead of JWT wallet address - Append account_type=gnosis query param when acting as a safe - Add isV2=true query param to underwrite endpoint - Add e2e tests verifying safe address routing and query params --- src/application/ports/backend-client-port.ts | 2 + src/cli/factoring/backend-commands.ts | 22 ++++--- src/cli/options/factoring-options.ts | 5 ++ src/infrastructure/http/backend-client.ts | 32 +++++++---- .../factoring/backend-commands.e2e.test.ts | 57 +++++++++++++++++-- 5 files changed, 94 insertions(+), 24 deletions(-) diff --git a/src/application/ports/backend-client-port.ts b/src/application/ports/backend-client-port.ts index 73da599..fd19ea2 100644 --- a/src/application/ports/backend-client-port.ts +++ b/src/application/ports/backend-client-port.ts @@ -26,6 +26,7 @@ export interface BackendClientService { chainId: number, poolAddress: string, body: UnderwriteRequest, + isSafe?: boolean, ) => Effect.Effect; /** POST /tapCredit/batch/{wallet}/chain/{chainId}/pool/{poolAddress} — batch tap-credit. */ @@ -35,6 +36,7 @@ export interface BackendClientService { chainId: number, poolAddress: string, body: TapCreditRequest, + isSafe?: boolean, ) => Effect.Effect; } diff --git a/src/cli/factoring/backend-commands.ts b/src/cli/factoring/backend-commands.ts index 4dc6e55..c5808cd 100644 --- a/src/cli/factoring/backend-commands.ts +++ b/src/cli/factoring/backend-commands.ts @@ -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'; @@ -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'; // ============================================================================ @@ -75,10 +75,13 @@ 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; @@ -86,7 +89,7 @@ const underwriteCommand = Command.make( const response = yield* client.underwrite(authToken, wallet, chainId, poolAddress, { claimIds: claimIdList, - }); + }, isSafe); for (const result of response.results) { yield* Console.log( @@ -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 @@ -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( diff --git a/src/cli/options/factoring-options.ts b/src/cli/options/factoring-options.ts index d2aa410..26ff653 100644 --- a/src/cli/options/factoring-options.ts +++ b/src/cli/options/factoring-options.ts @@ -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, +); diff --git a/src/infrastructure/http/backend-client.ts b/src/infrastructure/http/backend-client.ts index a7d2f93..dbe8a19 100644 --- a/src/infrastructure/http/backend-client.ts +++ b/src/infrastructure/http/backend-client.ts @@ -36,19 +36,26 @@ export const BackendClientLive = Layer.succeed(BackendClientService, { body: signature, }), - underwrite: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: UnderwriteRequest) => - fetchJson(`${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( + `${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( - `${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( + `${UNDERWRITER_BASE_URL}/tapCredit/batch/${wallet}/chain/${chainId}/pool/${poolAddress}${params}`, { method: 'POST', headers: { @@ -57,5 +64,6 @@ export const BackendClientLive = Layer.succeed(BackendClientService, { }, body: JSON.stringify(body), }, - ), + ); + }, }); diff --git a/test/cli/factoring/backend-commands.e2e.test.ts b/test/cli/factoring/backend-commands.e2e.test.ts index 8f09dca..7e61582 100644 --- a/test/cli/factoring/backend-commands.e2e.test.ts +++ b/test/cli/factoring/backend-commands.e2e.test.ts @@ -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 => @@ -53,16 +56,18 @@ const readBody = (req: IncomingMessage): Promise => 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] ?? ''; @@ -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, @@ -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, @@ -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'); + }); }); // ============================================================================ @@ -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'); + }); });