From e552c1c4980d1a8298763c760d4d774ddd96ec9e Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 13:59:19 -0400 Subject: [PATCH 1/9] feat: add approve commands (create-claim, nft, erc20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `bulla approve` subcommand with three operations: - `create-claim` — BullaApprovalRegistry.approveCreateClaim - `nft` — BullaClaimV2.approve (ERC721) - `erc20` — ERC20.approve Each has build (unsigned tx) and execute (sign+send) variants. Adds ABIs, domain types, encoder port/adapter, service layer, CLI options/validation/commands, and registry support for bullaApprovalRegistry + bullaClaimV2 contract addresses. Co-Authored-By: Claude Opus 4.6 --- scripts/sync-registry.ts | 8 +- src/application/ports/approve-encoder-port.ts | 11 ++ src/application/ports/registry-port.ts | 2 + src/application/services/approve-service.ts | 58 ++++++ src/cli/approve/commands.ts | 176 ++++++++++++++++++ src/cli/approve/options.ts | 39 ++++ src/cli/approve/validation.ts | 58 ++++++ src/cli/commands/approve.ts | 7 + src/domain/types/approve.ts | 29 +++ src/generated/registry.ts | 22 ++- src/index.ts | 3 +- .../abi/bulla-approval-registry.ts | 15 ++ src/infrastructure/abi/bulla-claim-v2.ts | 13 ++ src/infrastructure/abi/erc20.ts | 13 ++ .../encoding/viem-approve-encoder.ts | 41 ++++ src/infrastructure/layers.ts | 2 + .../registry/static-registry-service.ts | 46 +++++ 17 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 src/application/ports/approve-encoder-port.ts create mode 100644 src/application/services/approve-service.ts create mode 100644 src/cli/approve/commands.ts create mode 100644 src/cli/approve/options.ts create mode 100644 src/cli/approve/validation.ts create mode 100644 src/cli/commands/approve.ts create mode 100644 src/domain/types/approve.ts create mode 100644 src/infrastructure/abi/bulla-approval-registry.ts create mode 100644 src/infrastructure/abi/bulla-claim-v2.ts create mode 100644 src/infrastructure/abi/erc20.ts create mode 100644 src/infrastructure/encoding/viem-approve-encoder.ts diff --git a/scripts/sync-registry.ts b/scripts/sync-registry.ts index a446d8d..9800a3d 100644 --- a/scripts/sync-registry.ts +++ b/scripts/sync-registry.ts @@ -68,6 +68,8 @@ async function main() { const bullaContractsV2 = network.contracts['bulla-contracts-v2']; const invoiceAddr = bullaContractsV2?.['bullaInvoice']; const frendLendV2Addr = bullaContractsV2?.['frendLendV2']; + const approvalRegistryAddr = bullaContractsV2?.['bullaApprovalRegistry']; + const claimV2Addr = bullaContractsV2?.['bullaClaimV2']; if (!instantPaymentAddr) { console.warn(` Warning: Missing bullaInstantPayment for chain ${chainId} (${network.name}), skipping`); @@ -76,7 +78,9 @@ async function main() { const invoicePart = invoiceAddr ? `, bullaInvoice: '${invoiceAddr}' as EthAddress` : ''; const frendLendV2Part = frendLendV2Addr ? `, frendLendV2: '${frendLendV2Addr}' as EthAddress` : ''; - contracts.push(` ${chainId}: { bullaInstantPayment: '${instantPaymentAddr}' as EthAddress${invoicePart}${frendLendV2Part} },`); + const approvalRegistryPart = approvalRegistryAddr ? `, bullaApprovalRegistry: '${approvalRegistryAddr}' as EthAddress` : ''; + const claimV2Part = claimV2Addr ? `, bullaClaimV2: '${claimV2Addr}' as EthAddress` : ''; + contracts.push(` ${chainId}: { bullaInstantPayment: '${instantPaymentAddr}' as EthAddress${invoicePart}${frendLendV2Part}${approvalRegistryPart}${claimV2Part} },`); subgraphs.push(` ${chainId}: '${network.graphql}',`); chainNames.push(` ${chainId}: '${network.name}',`); @@ -109,6 +113,8 @@ export interface ChainContracts { readonly bullaInstantPayment: EthAddress; readonly bullaInvoice?: EthAddress; readonly frendLendV2?: EthAddress; + readonly bullaApprovalRegistry?: EthAddress; + readonly bullaClaimV2?: EthAddress; } export const REGISTRY: Record = { diff --git a/src/application/ports/approve-encoder-port.ts b/src/application/ports/approve-encoder-port.ts new file mode 100644 index 0000000..ab6c0cd --- /dev/null +++ b/src/application/ports/approve-encoder-port.ts @@ -0,0 +1,11 @@ +import { Context, Effect } from 'effect'; +import type { Hex } from '../../domain/types/eth.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams } from '../../domain/types/approve.js'; + +export interface ApproveEncoderService { + encodeApproveCreateClaim(params: Omit): Effect.Effect; + encodeApproveNft(params: Omit): Effect.Effect; + encodeApproveErc20(params: Omit): Effect.Effect; +} + +export const ApproveEncoderService = Context.GenericTag('@services/ApproveEncoderService'); diff --git a/src/application/ports/registry-port.ts b/src/application/ports/registry-port.ts index e095519..49b9180 100644 --- a/src/application/ports/registry-port.ts +++ b/src/application/ports/registry-port.ts @@ -6,6 +6,8 @@ export interface RegistryService { readonly getInstantPaymentAddress: (chainId: ChainId) => Effect.Effect; readonly getInvoiceAddress: (chainId: ChainId) => Effect.Effect; readonly getFrendLendAddress: (chainId: ChainId) => Effect.Effect; + readonly getApprovalRegistryAddress: (chainId: ChainId) => Effect.Effect; + readonly getClaimAddress: (chainId: ChainId) => Effect.Effect; readonly validateFactoringPool: ( chainId: ChainId, address: EthAddress, diff --git a/src/application/services/approve-service.ts b/src/application/services/approve-service.ts new file mode 100644 index 0000000..f8b71da --- /dev/null +++ b/src/application/services/approve-service.ts @@ -0,0 +1,58 @@ +import { Effect } from 'effect'; +import type { ContractNotFoundError, UnsupportedChainError } from '../../domain/errors.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams } from '../../domain/types/approve.js'; +import type { UnsignedTransaction } from '../../domain/types/transaction.js'; +import { ApproveEncoderService } from '../ports/approve-encoder-port.js'; +import { RegistryService } from '../ports/registry-port.js'; + +export const buildApproveCreateClaim = ( + params: ApproveCreateClaimParams, +): Effect.Effect => + Effect.gen(function* () { + const registry = yield* RegistryService; + const encoder = yield* ApproveEncoderService; + + const contractAddress = yield* registry.getApprovalRegistryAddress(params.chainId); + const data = yield* encoder.encodeApproveCreateClaim(params); + + return { + to: contractAddress, + value: '0', + data, + operation: 0 as const, + }; + }); + +export const buildApproveNft = ( + params: ApproveNftParams, +): Effect.Effect => + Effect.gen(function* () { + const registry = yield* RegistryService; + const encoder = yield* ApproveEncoderService; + + const contractAddress = yield* registry.getClaimAddress(params.chainId); + const data = yield* encoder.encodeApproveNft(params); + + return { + to: contractAddress, + value: '0', + data, + operation: 0 as const, + }; + }); + +export const buildApproveErc20 = ( + params: ApproveErc20Params, +): Effect.Effect => + Effect.gen(function* () { + const encoder = yield* ApproveEncoderService; + + const data = yield* encoder.encodeApproveErc20(params); + + return { + to: params.token, + value: '0', + data, + operation: 0 as const, + }; + }); diff --git a/src/cli/approve/commands.ts b/src/cli/approve/commands.ts new file mode 100644 index 0000000..73db3c4 --- /dev/null +++ b/src/cli/approve/commands.ts @@ -0,0 +1,176 @@ +import { Command } from '@effect/cli'; +import { Console, Effect, Option } from 'effect'; +import { buildApproveCreateClaim, buildApproveErc20, buildApproveNft } from '../../application/services/approve-service.js'; +import { sendTransaction } from '../../application/services/transaction-utils.js'; +import type { Hex } from '../../domain/types/eth.js'; +import { makeSignerLayer } from '../../infrastructure/layers.js'; +import { formatResult, formatTransaction, type OutputFormat } from '../formatters/index.js'; +import { chainOption, formatOption, rpcUrlOption } from '../options/common.js'; +import { privateKeyOption } from '../options/pay-options.js'; +import { + approvalCountOption, + approvalTypeOption, + approveAmountOption, + approveClaimIdOption, + approveToOption, + bindingAllowedOption, + controllerOption, + erc20TokenOption, + spenderOption, +} from './options.js'; +import { + validateApproveCreateClaimParams, + validateApproveErc20Params, + validateApproveNftParams, +} from './validation.js'; + +// ============================================================================ +// APPROVE CREATE-CLAIM +// ============================================================================ + +const approveCreateClaimBuildCommand = Command.make( + 'build', + { + chain: chainOption, + controller: controllerOption, + approvalType: approvalTypeOption, + approvalCount: approvalCountOption, + bindingAllowed: bindingAllowedOption, + format: formatOption, + }, + ({ chain, controller, approvalType, approvalCount, bindingAllowed, format }) => + Effect.gen(function* () { + const params = yield* validateApproveCreateClaimParams(chain, controller, approvalType, approvalCount, bindingAllowed); + const tx = yield* buildApproveCreateClaim(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned approveCreateClaim transaction (no private key required)')); + +const approveCreateClaimExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + controller: controllerOption, + approvalType: approvalTypeOption, + approvalCount: approvalCountOption, + bindingAllowed: bindingAllowedOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, controller, approvalType, approvalCount, bindingAllowed, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateApproveCreateClaimParams(chain, controller, approvalType, approvalCount, bindingAllowed); + const tx = yield* buildApproveCreateClaim(params); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an approveCreateClaim transaction (requires private key)')); + +const approveCreateClaimCommand = Command.make('create-claim', {}).pipe( + Command.withDescription('Approve a controller to create claims on your behalf (BullaApprovalRegistry)'), + Command.withSubcommands([approveCreateClaimBuildCommand, approveCreateClaimExecuteCommand]), +); + +// ============================================================================ +// APPROVE NFT +// ============================================================================ + +const approveNftBuildCommand = Command.make( + 'build', + { + chain: chainOption, + to: approveToOption, + claimId: approveClaimIdOption, + format: formatOption, + }, + ({ chain, to, claimId, format }) => + Effect.gen(function* () { + const params = yield* validateApproveNftParams(chain, to, claimId); + const tx = yield* buildApproveNft(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction (no private key required)')); + +const approveNftExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + to: approveToOption, + claimId: approveClaimIdOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, to, claimId, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateApproveNftParams(chain, to, claimId); + const tx = yield* buildApproveNft(params); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an ERC721 approve transaction (requires private key)')); + +const approveNftCommand = Command.make('nft', {}).pipe( + Command.withDescription('Approve an address for a specific claim NFT (BullaClaimV2)'), + Command.withSubcommands([approveNftBuildCommand, approveNftExecuteCommand]), +); + +// ============================================================================ +// APPROVE ERC20 +// ============================================================================ + +const approveErc20BuildCommand = Command.make( + 'build', + { + chain: chainOption, + token: erc20TokenOption, + spender: spenderOption, + amount: approveAmountOption, + format: formatOption, + }, + ({ chain, token, spender, amount, format }) => + Effect.gen(function* () { + const params = yield* validateApproveErc20Params(chain, token, spender, amount); + const tx = yield* buildApproveErc20(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned ERC20 approve transaction (no private key required)')); + +const approveErc20ExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + token: erc20TokenOption, + spender: spenderOption, + amount: approveAmountOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, token, spender, amount, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateApproveErc20Params(chain, token, spender, amount); + const tx = yield* buildApproveErc20(params); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an ERC20 approve transaction (requires private key)')); + +const approveErc20Command = Command.make('erc20', {}).pipe( + Command.withDescription('Approve a spender for an ERC20 token'), + Command.withSubcommands([approveErc20BuildCommand, approveErc20ExecuteCommand]), +); + +// ============================================================================ +// EXPORT ALL APPROVE COMMANDS +// ============================================================================ + +export const approveCommands = [ + approveCreateClaimCommand, + approveNftCommand, + approveErc20Command, +] as const; diff --git a/src/cli/approve/options.ts b/src/cli/approve/options.ts new file mode 100644 index 0000000..3376cca --- /dev/null +++ b/src/cli/approve/options.ts @@ -0,0 +1,39 @@ +import { Options } from '@effect/cli'; + +export const controllerOption = Options.text('controller').pipe( + Options.withDescription('Controller contract address (e.g. BullaInvoice or BullaFrendLendV2)'), +); + +export const approvalTypeOption = Options.choice('approval-type', ['unapproved', 'creditor-only', 'debtor-only', 'approved']).pipe( + Options.withDescription('Approval type: unapproved, creditor-only, debtor-only, or approved'), +); + +export const approvalCountOption = Options.text('approval-count').pipe( + Options.withDefault('18446744073709551615'), + Options.withDescription('Number of claims the controller can create (default: unlimited)'), +); + +export const bindingAllowedOption = Options.boolean('binding-allowed').pipe( + Options.withDefault(false), + Options.withDescription('Whether the controller can create bound claims (default: false)'), +); + +export const approveToOption = Options.text('to').pipe( + Options.withDescription('Address to approve for the NFT'), +); + +export const approveClaimIdOption = Options.text('claim-id').pipe( + Options.withDescription('Claim/token ID to approve'), +); + +export const erc20TokenOption = Options.text('token').pipe( + Options.withDescription('ERC20 token contract address'), +); + +export const spenderOption = Options.text('spender').pipe( + Options.withDescription('Address to approve as spender'), +); + +export const approveAmountOption = Options.text('amount').pipe( + Options.withDescription("Amount to approve (in the token's smallest unit)"), +); diff --git a/src/cli/approve/validation.ts b/src/cli/approve/validation.ts new file mode 100644 index 0000000..3e0db5d --- /dev/null +++ b/src/cli/approve/validation.ts @@ -0,0 +1,58 @@ +import { Either, Option } from 'effect'; +import type { InvalidAddressError, InvalidAmountError, InvalidChainError } from '../../domain/errors.js'; +import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams } from '../../domain/types/approve.js'; +import { validateAddress, validateAmount, validateChainId } from '../../domain/validation/eth.js'; + +type ValidationError = InvalidChainError | InvalidAddressError | InvalidAmountError; + +const APPROVAL_TYPE_MAP: Record = { + 'unapproved': CreateClaimApprovalType.Unapproved, + 'creditor-only': CreateClaimApprovalType.CreditorOnly, + 'debtor-only': CreateClaimApprovalType.DebtorOnly, + 'approved': CreateClaimApprovalType.Approved, +}; + +export const validateApproveCreateClaimParams = ( + chain: Option.Option, + controller: string, + approvalType: string, + approvalCount: string, + bindingAllowed: boolean, +): Either.Either => + Either.gen(function* () { + return { + chainId: yield* validateChainId(chain), + controller: yield* validateAddress(controller), + approvalType: APPROVAL_TYPE_MAP[approvalType]!, + approvalCount: BigInt(approvalCount), + isBindingAllowed: bindingAllowed, + }; + }); + +export const validateApproveNftParams = ( + chain: Option.Option, + to: string, + claimId: string, +): Either.Either => + Either.gen(function* () { + return { + chainId: yield* validateChainId(chain), + to: yield* validateAddress(to), + claimId: yield* validateAmount(claimId), + }; + }); + +export const validateApproveErc20Params = ( + chain: Option.Option, + token: string, + spender: string, + amount: string, +): Either.Either => + Either.gen(function* () { + return { + chainId: yield* validateChainId(chain), + token: yield* validateAddress(token), + spender: yield* validateAddress(spender), + amount: yield* validateAmount(amount), + }; + }); diff --git a/src/cli/commands/approve.ts b/src/cli/commands/approve.ts new file mode 100644 index 0000000..e11e10d --- /dev/null +++ b/src/cli/commands/approve.ts @@ -0,0 +1,7 @@ +import { Command } from '@effect/cli'; +import { approveCommands } from '../approve/commands.js'; + +export const approveCommand = Command.make('approve', {}).pipe( + Command.withDescription('Approval operations (create-claim, NFT, ERC20)'), + Command.withSubcommands([...approveCommands]), +); diff --git a/src/domain/types/approve.ts b/src/domain/types/approve.ts new file mode 100644 index 0000000..6be8221 --- /dev/null +++ b/src/domain/types/approve.ts @@ -0,0 +1,29 @@ +import type { ChainId, EthAddress } from './eth.js'; + +export enum CreateClaimApprovalType { + Unapproved = 0, + CreditorOnly = 1, + DebtorOnly = 2, + Approved = 3, +} + +export interface ApproveCreateClaimParams { + chainId: ChainId; + controller: EthAddress; + approvalType: CreateClaimApprovalType; + approvalCount: bigint; + isBindingAllowed: boolean; +} + +export interface ApproveNftParams { + chainId: ChainId; + to: EthAddress; + claimId: bigint; +} + +export interface ApproveErc20Params { + chainId: ChainId; + token: EthAddress; + spender: EthAddress; + amount: bigint; +} diff --git a/src/generated/registry.ts b/src/generated/registry.ts index bd69e85..4b4392e 100644 --- a/src/generated/registry.ts +++ b/src/generated/registry.ts @@ -8,20 +8,22 @@ export interface ChainContracts { readonly bullaInstantPayment: EthAddress; readonly bullaInvoice?: EthAddress; readonly frendLendV2?: EthAddress; + readonly bullaApprovalRegistry?: EthAddress; + readonly bullaClaimV2?: EthAddress; } export const REGISTRY: Record = { - 1: { bullaInstantPayment: '0xec6013D62Af8dfB65B8248204Dd1913d2f1F0181' as EthAddress, bullaInvoice: '0xfe2631bcb3e622750b6fbb605a416173ffa3a770' as EthAddress, frendLendV2: '0x1097b7ecf0721aaffff147cf7bec154422896317' as EthAddress }, - 10: { bullaInstantPayment: '0xbe25A1086DE2b587B2D20E4B14c442cdA2437945' as EthAddress, bullaInvoice: '0x12c0edd1de5578e02390209224f22fe1ca2d0d40' as EthAddress, frendLendV2: '0x17eb35e2205820aa4ed30704226f2517507f433b' as EthAddress }, - 56: { bullaInstantPayment: '0xa9a04d0C22B6f264BC72a108d124f25BD199c928' as EthAddress, bullaInvoice: '0xba80d22b532eb1a2326334f35565af551f9c8af7' as EthAddress, frendLendV2: '0xf34aa523a1cf4d91a3cb1c53cb50acd6eed0b153' as EthAddress }, - 100: { bullaInstantPayment: '0xA2d3332AdC23109129651A85388eB6561C69074A' as EthAddress, bullaInvoice: '0xd60f8fc651bdcee2c6ed9914d610c0e22ce190d6' as EthAddress, frendLendV2: '0x7c3e51e84705fed1832bd6aabb9edd3e4681374e' as EthAddress }, - 137: { bullaInstantPayment: '0x712359c61534c5da10821c09d0e9c7c2312e1d91' as EthAddress, bullaInvoice: '0x7c2cc85cb30844b81524e703f04a5ee98e3313fb' as EthAddress, frendLendV2: '0x8a6b12c9980249f4e52b1e2a31cdbc23d3354629' as EthAddress }, + 1: { bullaInstantPayment: '0xec6013D62Af8dfB65B8248204Dd1913d2f1F0181' as EthAddress, bullaInvoice: '0xfe2631bcb3e622750b6fbb605a416173ffa3a770' as EthAddress, frendLendV2: '0x1097b7ecf0721aaffff147cf7bec154422896317' as EthAddress, bullaApprovalRegistry: '0x998d8d1ef0984fbd226eb61d97e291b2d3eb7bfb' as EthAddress, bullaClaimV2: '0x10a55a4dbd24fa188eed98a2adae2ebff0ef1219' as EthAddress }, + 10: { bullaInstantPayment: '0xbe25A1086DE2b587B2D20E4B14c442cdA2437945' as EthAddress, bullaInvoice: '0x12c0edd1de5578e02390209224f22fe1ca2d0d40' as EthAddress, frendLendV2: '0x17eb35e2205820aa4ed30704226f2517507f433b' as EthAddress, bullaApprovalRegistry: '0x4d5ea6f590a5bec220995f63b31fa9585dbed857' as EthAddress, bullaClaimV2: '0x54280b44f8725c89d57d224c4d5a2f8870549471' as EthAddress }, + 56: { bullaInstantPayment: '0xa9a04d0C22B6f264BC72a108d124f25BD199c928' as EthAddress, bullaInvoice: '0xba80d22b532eb1a2326334f35565af551f9c8af7' as EthAddress, frendLendV2: '0xf34aa523a1cf4d91a3cb1c53cb50acd6eed0b153' as EthAddress, bullaApprovalRegistry: '0x6985d6af038f177438a6681d1f64d4409dc8aac2' as EthAddress, bullaClaimV2: '0xbe25a1086de2b587b2d20e4b14c442cda2437945' as EthAddress }, + 100: { bullaInstantPayment: '0xA2d3332AdC23109129651A85388eB6561C69074A' as EthAddress, bullaInvoice: '0xd60f8fc651bdcee2c6ed9914d610c0e22ce190d6' as EthAddress, frendLendV2: '0x7c3e51e84705fed1832bd6aabb9edd3e4681374e' as EthAddress, bullaApprovalRegistry: '0xb358fb00a1403fe38099dba2946631f092ea21b4' as EthAddress, bullaClaimV2: '0xf28c50eb5996508b558f0ffdde637c4385117430' as EthAddress }, + 137: { bullaInstantPayment: '0x712359c61534c5da10821c09d0e9c7c2312e1d91' as EthAddress, bullaInvoice: '0x7c2cc85cb30844b81524e703f04a5ee98e3313fb' as EthAddress, frendLendV2: '0x8a6b12c9980249f4e52b1e2a31cdbc23d3354629' as EthAddress, bullaApprovalRegistry: '0xf446785eb5e7ebeafd89e7a0ec1fe4ec37686486' as EthAddress, bullaClaimV2: '0x0313433613f24c73efc15c5c74408f40b462fd9e' as EthAddress }, 151: { bullaInstantPayment: '0xce704a7Fae206ad009852258dDD8574B844eDa3b' as EthAddress }, - 8453: { bullaInstantPayment: '0x26719d2A1073291559A9F5465Fafe73972B31b1f' as EthAddress, bullaInvoice: '0x1E1d535a41515D3D2c29C1524C825236D67733E1' as EthAddress, frendLendV2: '0x777A7966464a4E5684FE95025aDb2AD56bdaE77B' as EthAddress }, - 42161: { bullaInstantPayment: '0x1b4DB52FD952F70d3D28bfbd406dB71940eD8cA9' as EthAddress, bullaInvoice: '0x74c62f475464a03a462578d65629240b34221c1b' as EthAddress, frendLendV2: '0x1a34dfd1ee17130228452f3d9cdda5908865d22d' as EthAddress }, - 42220: { bullaInstantPayment: '0x48D283521Ff91a1e83A16a7138B549C34BeDD44c' as EthAddress, bullaInvoice: '0xdeb31d99d92d92988b2cce4312c807fbf4406f4f' as EthAddress, frendLendV2: '0xcc32679a9bbfce9e49d1eba4faf42e68a227bd06' as EthAddress }, - 43114: { bullaInstantPayment: '0xFcA0E74af938919E43541D40330212324C8aF27F' as EthAddress, bullaInvoice: '0xd60f8fc651bdcee2c6ed9914d610c0e22ce190d6' as EthAddress, frendLendV2: '0x7c3e51e84705fed1832bd6aabb9edd3e4681374e' as EthAddress }, - 11155111: { bullaInstantPayment: '0x1cD1A83C2965CB7aD55d60551877Eb390e9C3d7A' as EthAddress, bullaInvoice: '0xa2c4B7239A0d179A923751cC75277fe139AB092F' as EthAddress, frendLendV2: '0x4d6A66D32CF34270e4cc9C9F201CA4dB650Be3f2' as EthAddress }, + 8453: { bullaInstantPayment: '0x26719d2A1073291559A9F5465Fafe73972B31b1f' as EthAddress, bullaInvoice: '0x1E1d535a41515D3D2c29C1524C825236D67733E1' as EthAddress, frendLendV2: '0x777A7966464a4E5684FE95025aDb2AD56bdaE77B' as EthAddress, bullaApprovalRegistry: '0x909820879c91F1EB51c44367414ccBD19Ed06B75' as EthAddress, bullaClaimV2: '0x8D59E594a3e4D0647C15887Cde5ECBfBE583b441' as EthAddress }, + 42161: { bullaInstantPayment: '0x1b4DB52FD952F70d3D28bfbd406dB71940eD8cA9' as EthAddress, bullaInvoice: '0x74c62f475464a03a462578d65629240b34221c1b' as EthAddress, frendLendV2: '0x1a34dfd1ee17130228452f3d9cdda5908865d22d' as EthAddress, bullaApprovalRegistry: '0x8948a00fac01110210f97f8f9fa107aa07e6f46c' as EthAddress, bullaClaimV2: '0xb58f4f651553d51d95c69f59364a9ee1ca554b7e' as EthAddress }, + 42220: { bullaInstantPayment: '0x48D283521Ff91a1e83A16a7138B549C34BeDD44c' as EthAddress, bullaInvoice: '0xdeb31d99d92d92988b2cce4312c807fbf4406f4f' as EthAddress, frendLendV2: '0xcc32679a9bbfce9e49d1eba4faf42e68a227bd06' as EthAddress, bullaApprovalRegistry: '0x5e4fd9bb839ebea7eaf1c74edf4e92ed5c1bd773' as EthAddress, bullaClaimV2: '0xd60f8fc651bdcee2c6ed9914d610c0e22ce190d6' as EthAddress }, + 43114: { bullaInstantPayment: '0xFcA0E74af938919E43541D40330212324C8aF27F' as EthAddress, bullaInvoice: '0xd60f8fc651bdcee2c6ed9914d610c0e22ce190d6' as EthAddress, frendLendV2: '0x7c3e51e84705fed1832bd6aabb9edd3e4681374e' as EthAddress, bullaApprovalRegistry: '0xb358fb00a1403fe38099dba2946631f092ea21b4' as EthAddress, bullaClaimV2: '0xf28c50eb5996508b558f0ffdde637c4385117430' as EthAddress }, + 11155111: { bullaInstantPayment: '0x1cD1A83C2965CB7aD55d60551877Eb390e9C3d7A' as EthAddress, bullaInvoice: '0xa2c4B7239A0d179A923751cC75277fe139AB092F' as EthAddress, frendLendV2: '0x4d6A66D32CF34270e4cc9C9F201CA4dB650Be3f2' as EthAddress, bullaApprovalRegistry: '0xb1F9a06D72F8737B4fcf4550f1C8EA769772Ad76' as EthAddress, bullaClaimV2: '0x0d9EF9d436fF341E500360a6B5E5750aB85BCCB6' as EthAddress }, }; export const SUBGRAPH_ENDPOINTS: Record = { diff --git a/src/index.ts b/src/index.ts index 77c8296..59a346b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command } from '@effect/cli'; import { NodeContext, NodeRuntime } from '@effect/platform-node'; import { Cause, Console, Effect, Layer } from 'effect'; import { createRequire } from 'node:module'; +import { approveCommand } from './cli/commands/approve.js'; import { factoringCommand } from './cli/commands/factoring.js'; import { frendlendCommand } from './cli/commands/frendlend.js'; import { invoiceCommand } from './cli/commands/invoice.js'; @@ -13,7 +14,7 @@ const { version } = require('../package.json') as { version: string }; const bullaCommand = Command.make('bulla', {}).pipe( Command.withDescription('Bulla Protocol CLI — build and send Bulla related transactions'), - Command.withSubcommands([payCommand, invoiceCommand, frendlendCommand, factoringCommand]), + Command.withSubcommands([payCommand, invoiceCommand, frendlendCommand, factoringCommand, approveCommand]), ); const cli = Command.run(bullaCommand, { diff --git a/src/infrastructure/abi/bulla-approval-registry.ts b/src/infrastructure/abi/bulla-approval-registry.ts new file mode 100644 index 0000000..b2a0b4f --- /dev/null +++ b/src/infrastructure/abi/bulla-approval-registry.ts @@ -0,0 +1,15 @@ +/** BullaApprovalRegistry ABI — declared `as const` for viem type inference. */ +export const bullaApprovalRegistryAbi = [ + { + inputs: [ + { internalType: 'address', name: 'controller', type: 'address' }, + { internalType: 'enum CreateClaimApprovalType', name: 'approvalType', type: 'uint8' }, + { internalType: 'uint64', name: 'approvalCount', type: 'uint64' }, + { internalType: 'bool', name: 'isBindingAllowed', type: 'bool' }, + ], + name: 'approveCreateClaim', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/src/infrastructure/abi/bulla-claim-v2.ts b/src/infrastructure/abi/bulla-claim-v2.ts new file mode 100644 index 0000000..935de9d --- /dev/null +++ b/src/infrastructure/abi/bulla-claim-v2.ts @@ -0,0 +1,13 @@ +/** BullaClaimV2 (ERC721) ABI — only the approve function. */ +export const bullaClaimV2Abi = [ + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'approve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/src/infrastructure/abi/erc20.ts b/src/infrastructure/abi/erc20.ts new file mode 100644 index 0000000..b2a5cf7 --- /dev/null +++ b/src/infrastructure/abi/erc20.ts @@ -0,0 +1,13 @@ +/** Minimal ERC20 ABI — only the approve function. */ +export const erc20Abi = [ + { + inputs: [ + { internalType: 'address', name: 'spender', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/src/infrastructure/encoding/viem-approve-encoder.ts b/src/infrastructure/encoding/viem-approve-encoder.ts new file mode 100644 index 0000000..7b96c41 --- /dev/null +++ b/src/infrastructure/encoding/viem-approve-encoder.ts @@ -0,0 +1,41 @@ +import { Effect, Layer } from 'effect'; +import { encodeFunctionData } from 'viem'; +import { ApproveEncoderService } from '../../application/ports/approve-encoder-port.js'; +import type { Hex } from '../../domain/types/eth.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams } from '../../domain/types/approve.js'; +import { bullaApprovalRegistryAbi } from '../abi/bulla-approval-registry.js'; +import { bullaClaimV2Abi } from '../abi/bulla-claim-v2.js'; +import { erc20Abi } from '../abi/erc20.js'; + +const encodeApproveCreateClaim = (params: Omit): Effect.Effect => + Effect.sync(() => + encodeFunctionData({ + abi: bullaApprovalRegistryAbi, + functionName: 'approveCreateClaim', + args: [params.controller, params.approvalType, params.approvalCount, params.isBindingAllowed], + }), + ); + +const encodeApproveNft = (params: Omit): Effect.Effect => + Effect.sync(() => + encodeFunctionData({ + abi: bullaClaimV2Abi, + functionName: 'approve', + args: [params.to, params.claimId], + }), + ); + +const encodeApproveErc20 = (params: Omit): Effect.Effect => + Effect.sync(() => + encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [params.spender, params.amount], + }), + ); + +export const ViemApproveEncoderLive = Layer.succeed(ApproveEncoderService, { + encodeApproveCreateClaim, + encodeApproveNft, + encodeApproveErc20, +}); diff --git a/src/infrastructure/layers.ts b/src/infrastructure/layers.ts index 5370175..d6fc6d4 100644 --- a/src/infrastructure/layers.ts +++ b/src/infrastructure/layers.ts @@ -1,5 +1,6 @@ import { Layer } from 'effect'; import type { Hex } from '../domain/types/eth.js'; +import { ViemApproveEncoderLive } from './encoding/viem-approve-encoder.js'; import { ViemFactoringEncoderLive } from './encoding/viem-factoring-encoder.js'; import { ViemFrendLendEncoderLive } from './encoding/viem-frendlend-encoder.js'; import { ViemInstantPaymentEncoderLive } from './encoding/viem-instant-payment-encoder.js'; @@ -16,6 +17,7 @@ export const BuildModeLayers = Layer.mergeAll( ViemInvoiceEncoderLive, ViemFrendLendEncoderLive, ViemFactoringEncoderLive, + ViemApproveEncoderLive, ); /** Additive signer layer for execute mode: requires a private key. */ diff --git a/src/infrastructure/registry/static-registry-service.ts b/src/infrastructure/registry/static-registry-service.ts index 38bce99..bf4d008 100644 --- a/src/infrastructure/registry/static-registry-service.ts +++ b/src/infrastructure/registry/static-registry-service.ts @@ -75,6 +75,52 @@ export const StaticRegistryServiceLive = Layer.succeed(RegistryService, { return Effect.succeed(address); }, + getApprovalRegistryAddress: (chainId: ChainId) => { + if (!isChainId(chainId)) { + return Effect.fail( + new UnsupportedChainError({ + chainId, + message: `Chain ${chainId} is not supported`, + }), + ); + } + + const address = REGISTRY[chainId]?.bullaApprovalRegistry; + if (!address) { + return Effect.fail( + new ContractNotFoundError({ + chainId, + contractName: 'BullaApprovalRegistry', + message: `No BullaApprovalRegistry contract found for chain ${chainId}`, + }), + ); + } + + return Effect.succeed(address); + }, + getClaimAddress: (chainId: ChainId) => { + if (!isChainId(chainId)) { + return Effect.fail( + new UnsupportedChainError({ + chainId, + message: `Chain ${chainId} is not supported`, + }), + ); + } + + const address = REGISTRY[chainId]?.bullaClaimV2; + if (!address) { + return Effect.fail( + new ContractNotFoundError({ + chainId, + contractName: 'BullaClaimV2', + message: `No BullaClaimV2 contract found for chain ${chainId}`, + }), + ); + } + + return Effect.succeed(address); + }, validateFactoringPool: (chainId: ChainId, address: EthAddress) => { if (!isChainId(chainId)) { return Effect.fail( From 80ab64e9031edc4a13856c7b1fd1a64d3d27d256 Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 14:52:22 -0400 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?default=20approval=20type,=20validation,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set 'approved' as default for --approval-type option - Add explicit validateApprovalType() with InvalidApprovalTypeError - Add unit tests for approve service (11 tests) and validation (16 tests) - Add e2e tests for all 3 approve build commands + help output Co-Authored-By: Claude Opus 4.6 --- src/cli/approve/options.ts | 3 +- src/cli/approve/validation.ts | 22 +- .../services/approve-service.test.ts | 188 +++++++++++++++++ test/cli/approve/validation.test.ts | 190 ++++++++++++++++++ test/e2e/approve.e2e.test.ts | 108 ++++++++++ 5 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 test/application/services/approve-service.test.ts create mode 100644 test/cli/approve/validation.test.ts create mode 100644 test/e2e/approve.e2e.test.ts diff --git a/src/cli/approve/options.ts b/src/cli/approve/options.ts index 3376cca..9bc98e5 100644 --- a/src/cli/approve/options.ts +++ b/src/cli/approve/options.ts @@ -5,7 +5,8 @@ export const controllerOption = Options.text('controller').pipe( ); export const approvalTypeOption = Options.choice('approval-type', ['unapproved', 'creditor-only', 'debtor-only', 'approved']).pipe( - Options.withDescription('Approval type: unapproved, creditor-only, debtor-only, or approved'), + Options.withDefault('approved'), + Options.withDescription('Approval type: unapproved, creditor-only, debtor-only, or approved (default: approved)'), ); export const approvalCountOption = Options.text('approval-count').pipe( diff --git a/src/cli/approve/validation.ts b/src/cli/approve/validation.ts index 3e0db5d..22ec8ce 100644 --- a/src/cli/approve/validation.ts +++ b/src/cli/approve/validation.ts @@ -3,7 +3,12 @@ import type { InvalidAddressError, InvalidAmountError, InvalidChainError } from import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams } from '../../domain/types/approve.js'; import { validateAddress, validateAmount, validateChainId } from '../../domain/validation/eth.js'; -type ValidationError = InvalidChainError | InvalidAddressError | InvalidAmountError; +export class InvalidApprovalTypeError { + readonly _tag = 'InvalidApprovalTypeError' as const; + constructor(readonly approvalType: string, readonly message: string) {} +} + +type ValidationError = InvalidChainError | InvalidAddressError | InvalidAmountError | InvalidApprovalTypeError; const APPROVAL_TYPE_MAP: Record = { 'unapproved': CreateClaimApprovalType.Unapproved, @@ -12,6 +17,19 @@ const APPROVAL_TYPE_MAP: Record = { 'approved': CreateClaimApprovalType.Approved, }; +const validateApprovalType = (approvalType: string): Either.Either => { + const mapped = APPROVAL_TYPE_MAP[approvalType]; + if (mapped === undefined) { + return Either.left( + new InvalidApprovalTypeError( + approvalType, + `Invalid approval type: '${approvalType}'. Must be one of: ${Object.keys(APPROVAL_TYPE_MAP).join(', ')}`, + ), + ); + } + return Either.right(mapped); +}; + export const validateApproveCreateClaimParams = ( chain: Option.Option, controller: string, @@ -23,7 +41,7 @@ export const validateApproveCreateClaimParams = ( return { chainId: yield* validateChainId(chain), controller: yield* validateAddress(controller), - approvalType: APPROVAL_TYPE_MAP[approvalType]!, + approvalType: yield* validateApprovalType(approvalType), approvalCount: BigInt(approvalCount), isBindingAllowed: bindingAllowed, }; diff --git a/test/application/services/approve-service.test.ts b/test/application/services/approve-service.test.ts new file mode 100644 index 0000000..76fdf3b --- /dev/null +++ b/test/application/services/approve-service.test.ts @@ -0,0 +1,188 @@ +import { Effect, Layer } from 'effect'; +import { encodeFunctionData } from 'viem'; +import { describe, expect, it } from 'vitest'; +import { ApproveEncoderService } from '../../../src/application/ports/approve-encoder-port.js'; +import { RegistryService } from '../../../src/application/ports/registry-port.js'; +import { + buildApproveCreateClaim, + buildApproveErc20, + buildApproveNft, +} from '../../../src/application/services/approve-service.js'; +import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams } from '../../../src/domain/types/approve.js'; +import type { ChainId, EthAddress, Hex } from '../../../src/domain/types/eth.js'; +import { bullaApprovalRegistryAbi } from '../../../src/infrastructure/abi/bulla-approval-registry.js'; +import { bullaClaimV2Abi } from '../../../src/infrastructure/abi/bulla-claim-v2.js'; +import { erc20Abi } from '../../../src/infrastructure/abi/erc20.js'; + +const APPROVAL_REGISTRY_ADDRESS = '0xb1F9a06D72F8737B4fcf4550f1C8EA769772Ad76' as EthAddress; +const CLAIM_V2_ADDRESS = '0x0d9EF9d436fF341E500360a6B5E5750aB85BCCB6' as EthAddress; +const CONTROLLER = '0xa2c4B7239A0d179A923751cC75277fe139AB092F' as EthAddress; +const SPENDER = '0x1234567890abcdef1234567890abcdef12345678' as EthAddress; +const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as EthAddress; + +const makeApproveCreateClaimParams = (overrides: Partial = {}): ApproveCreateClaimParams => ({ + chainId: 11155111 as ChainId, + controller: CONTROLLER, + approvalType: CreateClaimApprovalType.Approved, + approvalCount: 18446744073709551615n, + isBindingAllowed: false, + ...overrides, +}); + +const makeApproveNftParams = (overrides: Partial = {}): ApproveNftParams => ({ + chainId: 11155111 as ChainId, + to: SPENDER, + claimId: 42n, + ...overrides, +}); + +const makeApproveErc20Params = (overrides: Partial = {}): ApproveErc20Params => ({ + chainId: 11155111 as ChainId, + token: TOKEN, + spender: SPENDER, + amount: 1000000n, + ...overrides, +}); + +// --- Test layers --- + +const TestRegistryService = Layer.succeed(RegistryService, { + getInstantPaymentAddress: () => Effect.succeed('0x0000000000000000000000000000000000000000' as EthAddress), + getInvoiceAddress: () => Effect.succeed('0x0000000000000000000000000000000000000000' as EthAddress), + getFrendLendAddress: () => Effect.succeed('0x0000000000000000000000000000000000000000' as EthAddress), + getApprovalRegistryAddress: () => Effect.succeed(APPROVAL_REGISTRY_ADDRESS), + getClaimAddress: () => Effect.succeed(CLAIM_V2_ADDRESS), + validateFactoringPool: () => Effect.succeed(true), +}); + +const TestApproveEncoder = Layer.succeed(ApproveEncoderService, { + encodeApproveCreateClaim: params => + Effect.succeed( + encodeFunctionData({ + abi: bullaApprovalRegistryAbi, + functionName: 'approveCreateClaim', + args: [ + params.controller as `0x${string}`, + params.approvalType, + params.approvalCount, + params.isBindingAllowed, + ], + }) as Hex, + ), + encodeApproveNft: params => + Effect.succeed( + encodeFunctionData({ + abi: bullaClaimV2Abi, + functionName: 'approve', + args: [params.to as `0x${string}`, params.claimId], + }) as Hex, + ), + encodeApproveErc20: params => + Effect.succeed( + encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [params.spender as `0x${string}`, params.amount], + }) as Hex, + ), +}); + +const BuildTestLayers = Layer.mergeAll(TestRegistryService, TestApproveEncoder); + +// --- Tests --- + +describe('buildApproveCreateClaim', () => { + it('produces an unsigned transaction targeting the approval registry', async () => { + const params = makeApproveCreateClaimParams(); + const result = await Effect.runPromise(buildApproveCreateClaim(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(APPROVAL_REGISTRY_ADDRESS); + expect(result.operation).toBe(0); + }); + + it('sets value to "0"', async () => { + const params = makeApproveCreateClaimParams(); + const result = await Effect.runPromise(buildApproveCreateClaim(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.value).toBe('0'); + }); + + it('encodes calldata starting with the approveCreateClaim function selector', async () => { + const params = makeApproveCreateClaimParams(); + const result = await Effect.runPromise(buildApproveCreateClaim(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.data).toMatch(/^0x[0-9a-f]{8}/); + expect(result.data.length).toBeGreaterThan(10); + }); + + it('produces valid hex calldata for all approval types', async () => { + for (const approvalType of [ + CreateClaimApprovalType.Unapproved, + CreateClaimApprovalType.CreditorOnly, + CreateClaimApprovalType.DebtorOnly, + CreateClaimApprovalType.Approved, + ]) { + const params = makeApproveCreateClaimParams({ approvalType }); + const result = await Effect.runPromise(buildApproveCreateClaim(params).pipe(Effect.provide(BuildTestLayers))); + expect(result.data).toMatch(/^0x[0-9a-f]+$/); + } + }); +}); + +describe('buildApproveNft', () => { + it('produces an unsigned transaction targeting the claim contract', async () => { + const params = makeApproveNftParams(); + const result = await Effect.runPromise(buildApproveNft(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(CLAIM_V2_ADDRESS); + expect(result.operation).toBe(0); + }); + + it('sets value to "0"', async () => { + const params = makeApproveNftParams(); + const result = await Effect.runPromise(buildApproveNft(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.value).toBe('0'); + }); + + it('encodes calldata starting with the approve function selector', async () => { + const params = makeApproveNftParams(); + const result = await Effect.runPromise(buildApproveNft(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.data).toMatch(/^0x[0-9a-f]{8}/); + expect(result.data.length).toBeGreaterThan(10); + }); +}); + +describe('buildApproveErc20', () => { + it('produces an unsigned transaction targeting the token contract', async () => { + const params = makeApproveErc20Params(); + const result = await Effect.runPromise(buildApproveErc20(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(TOKEN); + expect(result.operation).toBe(0); + }); + + it('sets value to "0"', async () => { + const params = makeApproveErc20Params(); + const result = await Effect.runPromise(buildApproveErc20(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.value).toBe('0'); + }); + + it('encodes calldata starting with the approve function selector', async () => { + const params = makeApproveErc20Params(); + const result = await Effect.runPromise(buildApproveErc20(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.data).toMatch(/^0x[0-9a-f]{8}/); + expect(result.data.length).toBeGreaterThan(10); + }); + + it('uses the token address from params as the transaction target', async () => { + const customToken = '0xdAC17F958D2ee523a2206206994597C13D831ec7' as EthAddress; + const params = makeApproveErc20Params({ token: customToken }); + const result = await Effect.runPromise(buildApproveErc20(params).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(customToken); + }); +}); diff --git a/test/cli/approve/validation.test.ts b/test/cli/approve/validation.test.ts new file mode 100644 index 0000000..62a6376 --- /dev/null +++ b/test/cli/approve/validation.test.ts @@ -0,0 +1,190 @@ +import { Either, Option } from 'effect'; +import { describe, expect, it } from 'vitest'; +import { CreateClaimApprovalType } from '../../../src/domain/types/approve.js'; +import { + validateApproveCreateClaimParams, + validateApproveErc20Params, + validateApproveNftParams, +} from '../../../src/cli/approve/validation.js'; + +const VALID_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; +const VALID_CHAIN = 11155111; + +describe('validateApproveCreateClaimParams', () => { + it('accepts valid params with default approval type', () => { + const result = validateApproveCreateClaimParams( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + 'approved', + '10', + false, + ); + + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect(result.right.chainId).toBe(VALID_CHAIN); + expect(result.right.controller).toBe(VALID_ADDRESS); + expect(result.right.approvalType).toBe(CreateClaimApprovalType.Approved); + expect(result.right.approvalCount).toBe(10n); + expect(result.right.isBindingAllowed).toBe(false); + } + }); + + it('maps all approval type strings correctly', () => { + const cases: Array<[string, CreateClaimApprovalType]> = [ + ['unapproved', CreateClaimApprovalType.Unapproved], + ['creditor-only', CreateClaimApprovalType.CreditorOnly], + ['debtor-only', CreateClaimApprovalType.DebtorOnly], + ['approved', CreateClaimApprovalType.Approved], + ]; + + for (const [input, expected] of cases) { + const result = validateApproveCreateClaimParams( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + input, + '1', + false, + ); + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect(result.right.approvalType).toBe(expected); + } + } + }); + + it('rejects invalid approval type', () => { + const result = validateApproveCreateClaimParams( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + 'invalid-type', + '1', + false, + ); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid chain ID', () => { + const result = validateApproveCreateClaimParams( + Option.some(999), + VALID_ADDRESS, + 'approved', + '1', + false, + ); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid controller address', () => { + const result = validateApproveCreateClaimParams( + Option.some(VALID_CHAIN), + 'not-an-address', + 'approved', + '1', + false, + ); + expect(Either.isLeft(result)).toBe(true); + }); + + it('accepts binding allowed flag', () => { + const result = validateApproveCreateClaimParams( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + 'approved', + '5', + true, + ); + + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect(result.right.isBindingAllowed).toBe(true); + } + }); + + it('supports large approval counts', () => { + const maxUint64 = '18446744073709551615'; + const result = validateApproveCreateClaimParams( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + 'approved', + maxUint64, + false, + ); + + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect(result.right.approvalCount).toBe(BigInt(maxUint64)); + } + }); +}); + +describe('validateApproveNftParams', () => { + it('accepts valid params', () => { + const result = validateApproveNftParams( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + '42', + ); + + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect(result.right.chainId).toBe(VALID_CHAIN); + expect(result.right.to).toBe(VALID_ADDRESS); + expect(result.right.claimId).toBe(42n); + } + }); + + it('rejects invalid chain ID', () => { + const result = validateApproveNftParams(Option.some(0), VALID_ADDRESS, '1'); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid to address', () => { + const result = validateApproveNftParams(Option.some(VALID_CHAIN), 'bad', '1'); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid claim ID', () => { + const result = validateApproveNftParams(Option.some(VALID_CHAIN), VALID_ADDRESS, '-1'); + expect(Either.isLeft(result)).toBe(true); + }); +}); + +describe('validateApproveErc20Params', () => { + it('accepts valid params', () => { + const result = validateApproveErc20Params( + Option.some(VALID_CHAIN), + VALID_ADDRESS, + VALID_ADDRESS, + '1000000', + ); + + expect(Either.isRight(result)).toBe(true); + if (Either.isRight(result)) { + expect(result.right.chainId).toBe(VALID_CHAIN); + expect(result.right.token).toBe(VALID_ADDRESS); + expect(result.right.spender).toBe(VALID_ADDRESS); + expect(result.right.amount).toBe(1000000n); + } + }); + + it('rejects invalid chain ID', () => { + const result = validateApproveErc20Params(Option.some(999), VALID_ADDRESS, VALID_ADDRESS, '1000'); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid token address', () => { + const result = validateApproveErc20Params(Option.some(VALID_CHAIN), 'bad', VALID_ADDRESS, '1000'); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid spender address', () => { + const result = validateApproveErc20Params(Option.some(VALID_CHAIN), VALID_ADDRESS, '0xZZZ', '1000'); + expect(Either.isLeft(result)).toBe(true); + }); + + it('rejects invalid amount', () => { + const result = validateApproveErc20Params(Option.some(VALID_CHAIN), VALID_ADDRESS, VALID_ADDRESS, '-1'); + expect(Either.isLeft(result)).toBe(true); + }); +}); diff --git a/test/e2e/approve.e2e.test.ts b/test/e2e/approve.e2e.test.ts new file mode 100644 index 0000000..b256759 --- /dev/null +++ b/test/e2e/approve.e2e.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { runCli } from './helpers/cli-runner.js'; +import { ANVIL_ACCOUNTS, SEPOLIA_CHAIN_ID } from './setup/constants.js'; + +describe('bulla approve build (e2e)', () => { + it('approve create-claim build outputs valid JSON transaction', () => { + const result = runCli([ + 'approve', + 'create-claim', + 'build', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--controller', + ANVIL_ACCOUNTS.account1.address, + '--approval-type', + 'approved', + '--approval-count', + '10', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const tx = JSON.parse(result.stdout); + expect(tx.to).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(tx.data).toMatch(/^0x[0-9a-f]+$/); + expect(tx.value).toBe('0'); + expect(tx.operation).toBe(0); + }); + + it('approve create-claim build uses approved as default approval type', () => { + const result = runCli([ + 'approve', + 'create-claim', + 'build', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--controller', + ANVIL_ACCOUNTS.account1.address, + '--approval-count', + '5', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const tx = JSON.parse(result.stdout); + expect(tx.to).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(tx.data).toMatch(/^0x[0-9a-f]+$/); + }); + + it('approve nft build outputs valid JSON transaction', () => { + const result = runCli([ + 'approve', + 'nft', + 'build', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--to', + ANVIL_ACCOUNTS.account1.address, + '--claim-id', + '42', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const tx = JSON.parse(result.stdout); + expect(tx.to).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(tx.data).toMatch(/^0x[0-9a-f]+$/); + expect(tx.value).toBe('0'); + expect(tx.operation).toBe(0); + }); + + it('approve erc20 build outputs valid JSON transaction', () => { + const result = runCli([ + 'approve', + 'erc20', + 'build', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--token', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '--spender', + ANVIL_ACCOUNTS.account1.address, + '--amount', + '1000000', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const tx = JSON.parse(result.stdout); + expect(tx.to.toLowerCase()).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + expect(tx.data).toMatch(/^0x[0-9a-f]+$/); + expect(tx.value).toBe('0'); + expect(tx.operation).toBe(0); + }); + + it('approve --help shows all subcommands', () => { + const result = runCli(['approve', '--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('create-claim'); + expect(result.stdout).toContain('nft'); + expect(result.stdout).toContain('erc20'); + }); +}); From 120355e1316abeee3adaaceee7ff124ffbe5b372 Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 15:21:30 -0400 Subject: [PATCH 3/9] feat: move approve-nft to invoice and frendlend subcommands Adds `bulla invoice approve-nft` and `bulla frendlend approve-nft` subcommands. Removes standalone `bulla approve nft`. Both target BullaClaimV2 via the registry since all claims are ERC721 on that contract regardless of which controller created them. Co-Authored-By: Claude Opus 4.6 --- src/cli/approve/commands.ts | 51 +------------------------ src/cli/commands/approve.ts | 2 +- src/cli/frendlend/commands.ts | 49 ++++++++++++++++++++++++ src/cli/invoice/commands.ts | 49 ++++++++++++++++++++++++ test/e2e/approve.e2e.test.ts | 70 +++++++++++++++++++++++++++-------- 5 files changed, 154 insertions(+), 67 deletions(-) diff --git a/src/cli/approve/commands.ts b/src/cli/approve/commands.ts index 73db3c4..7bdc5b2 100644 --- a/src/cli/approve/commands.ts +++ b/src/cli/approve/commands.ts @@ -1,6 +1,6 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; -import { buildApproveCreateClaim, buildApproveErc20, buildApproveNft } from '../../application/services/approve-service.js'; +import { buildApproveCreateClaim, buildApproveErc20 } from '../../application/services/approve-service.js'; import { sendTransaction } from '../../application/services/transaction-utils.js'; import type { Hex } from '../../domain/types/eth.js'; import { makeSignerLayer } from '../../infrastructure/layers.js'; @@ -11,8 +11,6 @@ import { approvalCountOption, approvalTypeOption, approveAmountOption, - approveClaimIdOption, - approveToOption, bindingAllowedOption, controllerOption, erc20TokenOption, @@ -21,7 +19,6 @@ import { import { validateApproveCreateClaimParams, validateApproveErc20Params, - validateApproveNftParams, } from './validation.js'; // ============================================================================ @@ -73,51 +70,6 @@ const approveCreateClaimCommand = Command.make('create-claim', {}).pipe( Command.withSubcommands([approveCreateClaimBuildCommand, approveCreateClaimExecuteCommand]), ); -// ============================================================================ -// APPROVE NFT -// ============================================================================ - -const approveNftBuildCommand = Command.make( - 'build', - { - chain: chainOption, - to: approveToOption, - claimId: approveClaimIdOption, - format: formatOption, - }, - ({ chain, to, claimId, format }) => - Effect.gen(function* () { - const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params); - yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); - }), -).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction (no private key required)')); - -const approveNftExecuteCommand = Command.make( - 'execute', - { - chain: chainOption, - to: approveToOption, - claimId: approveClaimIdOption, - privateKey: privateKeyOption, - rpcUrl: rpcUrlOption, - format: formatOption, - }, - ({ chain, to, claimId, privateKey, rpcUrl, format }) => - Effect.gen(function* () { - const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params); - const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); - const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); - yield* Console.log(formatResult(result, format as OutputFormat)); - }), -).pipe(Command.withDescription('Sign and send an ERC721 approve transaction (requires private key)')); - -const approveNftCommand = Command.make('nft', {}).pipe( - Command.withDescription('Approve an address for a specific claim NFT (BullaClaimV2)'), - Command.withSubcommands([approveNftBuildCommand, approveNftExecuteCommand]), -); - // ============================================================================ // APPROVE ERC20 // ============================================================================ @@ -171,6 +123,5 @@ const approveErc20Command = Command.make('erc20', {}).pipe( export const approveCommands = [ approveCreateClaimCommand, - approveNftCommand, approveErc20Command, ] as const; diff --git a/src/cli/commands/approve.ts b/src/cli/commands/approve.ts index e11e10d..71d394c 100644 --- a/src/cli/commands/approve.ts +++ b/src/cli/commands/approve.ts @@ -2,6 +2,6 @@ import { Command } from '@effect/cli'; import { approveCommands } from '../approve/commands.js'; export const approveCommand = Command.make('approve', {}).pipe( - Command.withDescription('Approval operations (create-claim, NFT, ERC20)'), + Command.withDescription('Approval operations (create-claim, ERC20)'), Command.withSubcommands([...approveCommands]), ); diff --git a/src/cli/frendlend/commands.ts b/src/cli/frendlend/commands.ts index 8d86756..ecc6d9a 100644 --- a/src/cli/frendlend/commands.ts +++ b/src/cli/frendlend/commands.ts @@ -1,5 +1,6 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; +import { buildApproveNft } from '../../application/services/approve-service.js'; import { buildAcceptLoan, buildImpairLoan, @@ -35,6 +36,8 @@ import { paymentAmountOption, periodsPerYearOption, } from '../options/invoice-options.js'; +import { approveClaimIdOption, approveToOption } from '../approve/options.js'; +import { validateApproveNftParams } from '../approve/validation.js'; import { privateKeyOption, tokenOption } from '../options/pay-options.js'; import { validateAcceptLoanParams, @@ -437,6 +440,51 @@ export const frendlendSetCallbackCommand = Command.make('set-callback', {}).pipe Command.withSubcommands([frendlendSetCallbackBuildCommand, frendlendSetCallbackExecuteCommand]), ); +// ============================================================================ +// APPROVE NFT (ERC721 approve on BullaClaimV2) +// ============================================================================ + +export const frendlendApproveNftBuildCommand = Command.make( + 'build', + { + chain: chainOption, + to: approveToOption, + claimId: approveClaimIdOption, + format: formatOption, + }, + ({ chain, to, claimId, format }) => + Effect.gen(function* () { + const params = yield* validateApproveNftParams(chain, to, claimId); + const tx = yield* buildApproveNft(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction for a loan claim NFT')); + +export const frendlendApproveNftExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + to: approveToOption, + claimId: approveClaimIdOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, to, claimId, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateApproveNftParams(chain, to, claimId); + const tx = yield* buildApproveNft(params); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an ERC721 approve transaction for a loan claim NFT')); + +export const frendlendApproveNftCommand = Command.make('approve-nft', {}).pipe( + Command.withDescription('Approve an address for a specific loan claim NFT (BullaClaimV2)'), + Command.withSubcommands([frendlendApproveNftBuildCommand, frendlendApproveNftExecuteCommand]), +); + // ============================================================================ // EXPORT ALL FRENDLEND COMMANDS // ============================================================================ @@ -449,4 +497,5 @@ export const frendlendCommands = [ frendlendImpairLoanCommand, frendlendMarkPaidCommand, frendlendSetCallbackCommand, + frendlendApproveNftCommand, ] as const; diff --git a/src/cli/invoice/commands.ts b/src/cli/invoice/commands.ts index fa7ba6a..6c12060 100644 --- a/src/cli/invoice/commands.ts +++ b/src/cli/invoice/commands.ts @@ -1,5 +1,6 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; +import { buildApproveNft } from '../../application/services/approve-service.js'; import { buildAcceptPurchaseOrder, buildCancelInvoice, @@ -34,6 +35,8 @@ import { paymentAmountOption, periodsPerYearOption, } from '../options/invoice-options.js'; +import { approveClaimIdOption, approveToOption } from '../approve/options.js'; +import { validateApproveNftParams } from '../approve/validation.js'; import { privateKeyOption, tokenOption } from '../options/pay-options.js'; import { validateAcceptPurchaseOrderParams, @@ -532,6 +535,51 @@ export const invoiceDeliverPoCommand = Command.make('deliver-po', {}).pipe( Command.withSubcommands([invoiceDeliverPoBuildCommand, invoiceDeliverPoExecuteCommand]), ); +// ============================================================================ +// APPROVE NFT (ERC721 approve on BullaClaimV2) +// ============================================================================ + +export const invoiceApproveNftBuildCommand = Command.make( + 'build', + { + chain: chainOption, + to: approveToOption, + claimId: approveClaimIdOption, + format: formatOption, + }, + ({ chain, to, claimId, format }) => + Effect.gen(function* () { + const params = yield* validateApproveNftParams(chain, to, claimId); + const tx = yield* buildApproveNft(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction for an invoice claim NFT')); + +export const invoiceApproveNftExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + to: approveToOption, + claimId: approveClaimIdOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, to, claimId, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateApproveNftParams(chain, to, claimId); + const tx = yield* buildApproveNft(params); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an ERC721 approve transaction for an invoice claim NFT')); + +export const invoiceApproveNftCommand = Command.make('approve-nft', {}).pipe( + Command.withDescription('Approve an address for a specific invoice claim NFT (BullaClaimV2)'), + Command.withSubcommands([invoiceApproveNftBuildCommand, invoiceApproveNftExecuteCommand]), +); + // ============================================================================ // EXPORT ALL INVOICE COMMANDS // ============================================================================ @@ -546,4 +594,5 @@ export const invoiceCommands = [ invoiceSetCallbackCommand, invoiceAcceptPoCommand, invoiceDeliverPoCommand, + invoiceApproveNftCommand, ] as const; diff --git a/test/e2e/approve.e2e.test.ts b/test/e2e/approve.e2e.test.ts index b256759..efafe9c 100644 --- a/test/e2e/approve.e2e.test.ts +++ b/test/e2e/approve.e2e.test.ts @@ -49,10 +49,45 @@ describe('bulla approve build (e2e)', () => { expect(tx.data).toMatch(/^0x[0-9a-f]+$/); }); - it('approve nft build outputs valid JSON transaction', () => { + it('approve erc20 build outputs valid JSON transaction', () => { const result = runCli([ 'approve', - 'nft', + 'erc20', + 'build', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--token', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '--spender', + ANVIL_ACCOUNTS.account1.address, + '--amount', + '1000000', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const tx = JSON.parse(result.stdout); + expect(tx.to.toLowerCase()).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + expect(tx.data).toMatch(/^0x[0-9a-f]+$/); + expect(tx.value).toBe('0'); + expect(tx.operation).toBe(0); + }); + + it('approve --help shows create-claim and erc20 subcommands', () => { + const result = runCli(['approve', '--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('create-claim'); + expect(result.stdout).toContain('erc20'); + }); +}); + +describe('approve-nft via invoice and frendlend (e2e)', () => { + it('invoice approve-nft build outputs valid JSON transaction', () => { + const result = runCli([ + 'invoice', + 'approve-nft', 'build', '--chain', String(SEPOLIA_CHAIN_ID), @@ -72,37 +107,40 @@ describe('bulla approve build (e2e)', () => { expect(tx.operation).toBe(0); }); - it('approve erc20 build outputs valid JSON transaction', () => { + it('frendlend approve-nft build outputs valid JSON transaction', () => { const result = runCli([ - 'approve', - 'erc20', + 'frendlend', + 'approve-nft', 'build', '--chain', String(SEPOLIA_CHAIN_ID), - '--token', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - '--spender', + '--to', ANVIL_ACCOUNTS.account1.address, - '--amount', - '1000000', + '--claim-id', + '42', '--format', 'json', ]); expect(result.exitCode).toBe(0); const tx = JSON.parse(result.stdout); - expect(tx.to.toLowerCase()).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + expect(tx.to).toMatch(/^0x[0-9a-fA-F]{40}$/); expect(tx.data).toMatch(/^0x[0-9a-f]+$/); expect(tx.value).toBe('0'); expect(tx.operation).toBe(0); }); - it('approve --help shows all subcommands', () => { - const result = runCli(['approve', '--help']); + it('invoice --help shows approve-nft subcommand', () => { + const result = runCli(['invoice', '--help']); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('create-claim'); - expect(result.stdout).toContain('nft'); - expect(result.stdout).toContain('erc20'); + expect(result.stdout).toContain('approve-nft'); + }); + + it('frendlend --help shows approve-nft subcommand', () => { + const result = runCli(['frendlend', '--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('approve-nft'); }); }); From 0e8e632e8b1c831b9d3c4bb009847f8dbaca406a Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 15:51:57 -0400 Subject: [PATCH 4/9] feat: target controller address for approve/transfer, add transfer-nft commands - buildApproveNft now targets the controller contract (BullaInvoice or BullaFrendLendV2) for controlled claims, not BullaClaimV2 directly - Added transfer-nft subcommand to invoice and frendlend - Added full lifecycle e2e tests: mint -> approve -> transfer -> verify ownership (per claim type, execute mode with anvil) - Extended BullaClaimV2 ABI with transferFrom and ownerOf Co-Authored-By: Claude Opus 4.6 --- src/application/ports/approve-encoder-port.ts | 3 +- src/application/services/approve-service.ts | 47 ++- src/cli/approve/options.ts | 8 +- src/cli/approve/validation.ts | 17 +- src/cli/frendlend/commands.ts | 60 +++- src/cli/invoice/commands.ts | 60 +++- src/domain/types/approve.ts | 7 + src/infrastructure/abi/bulla-claim-v2.ts | 20 +- .../encoding/viem-approve-encoder.ts | 12 +- .../services/approve-service.test.ts | 85 ++++- test/e2e/approve.e2e.test.ts | 294 ++++++++++++++---- 11 files changed, 533 insertions(+), 80 deletions(-) diff --git a/src/application/ports/approve-encoder-port.ts b/src/application/ports/approve-encoder-port.ts index ab6c0cd..82f3b09 100644 --- a/src/application/ports/approve-encoder-port.ts +++ b/src/application/ports/approve-encoder-port.ts @@ -1,11 +1,12 @@ import { Context, Effect } from 'effect'; import type { Hex } from '../../domain/types/eth.js'; -import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams } from '../../domain/types/approve.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams, TransferNftParams } from '../../domain/types/approve.js'; export interface ApproveEncoderService { encodeApproveCreateClaim(params: Omit): Effect.Effect; encodeApproveNft(params: Omit): Effect.Effect; encodeApproveErc20(params: Omit): Effect.Effect; + encodeTransferNft(params: Omit): Effect.Effect; } export const ApproveEncoderService = Context.GenericTag('@services/ApproveEncoderService'); diff --git a/src/application/services/approve-service.ts b/src/application/services/approve-service.ts index f8b71da..188de00 100644 --- a/src/application/services/approve-service.ts +++ b/src/application/services/approve-service.ts @@ -1,6 +1,7 @@ import { Effect } from 'effect'; import type { ContractNotFoundError, UnsupportedChainError } from '../../domain/errors.js'; -import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams } from '../../domain/types/approve.js'; +import type { ChainId, EthAddress } from '../../domain/types/eth.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams, TransferNftParams } from '../../domain/types/approve.js'; import type { UnsignedTransaction } from '../../domain/types/transaction.js'; import { ApproveEncoderService } from '../ports/approve-encoder-port.js'; import { RegistryService } from '../ports/registry-port.js'; @@ -23,14 +24,19 @@ export const buildApproveCreateClaim = ( }; }); +/** + * Build an ERC721 approve transaction targeting a specific contract. + * For controlled claims, the target is the controller contract (BullaInvoice, BullaFrendLendV2). + * For uncontrolled claims, the target is BullaClaimV2 directly. + */ export const buildApproveNft = ( params: ApproveNftParams, + getContractAddress: (chainId: ChainId) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { - const registry = yield* RegistryService; const encoder = yield* ApproveEncoderService; - const contractAddress = yield* registry.getClaimAddress(params.chainId); + const contractAddress = yield* getContractAddress(params.chainId); const data = yield* encoder.encodeApproveNft(params); return { @@ -41,6 +47,41 @@ export const buildApproveNft = ( }; }); +/** Registry lookup for invoice controller address */ +export const getInvoiceControllerAddress = (chainId: ChainId) => + RegistryService.pipe(Effect.flatMap(r => r.getInvoiceAddress(chainId))); + +/** Registry lookup for frendlend controller address */ +export const getFrendLendControllerAddress = (chainId: ChainId) => + RegistryService.pipe(Effect.flatMap(r => r.getFrendLendAddress(chainId))); + +/** Registry lookup for BullaClaimV2 address (uncontrolled claims) */ +export const getClaimContractAddress = (chainId: ChainId) => + RegistryService.pipe(Effect.flatMap(r => r.getClaimAddress(chainId))); + +/** + * Build an ERC721 transferFrom transaction targeting a specific contract. + * For controlled claims, the target is the controller contract. + * For uncontrolled claims, the target is BullaClaimV2 directly. + */ +export const buildTransferNft = ( + params: TransferNftParams, + getContractAddress: (chainId: ChainId) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const encoder = yield* ApproveEncoderService; + + const contractAddress = yield* getContractAddress(params.chainId); + const data = yield* encoder.encodeTransferNft(params); + + return { + to: contractAddress, + value: '0', + data, + operation: 0 as const, + }; + }); + export const buildApproveErc20 = ( params: ApproveErc20Params, ): Effect.Effect => diff --git a/src/cli/approve/options.ts b/src/cli/approve/options.ts index 9bc98e5..2d768dd 100644 --- a/src/cli/approve/options.ts +++ b/src/cli/approve/options.ts @@ -20,11 +20,15 @@ export const bindingAllowedOption = Options.boolean('binding-allowed').pipe( ); export const approveToOption = Options.text('to').pipe( - Options.withDescription('Address to approve for the NFT'), + Options.withDescription('Address to approve or transfer to'), +); + +export const approveFromOption = Options.text('from').pipe( + Options.withDescription('Address to transfer the NFT from (current owner)'), ); export const approveClaimIdOption = Options.text('claim-id').pipe( - Options.withDescription('Claim/token ID to approve'), + Options.withDescription('Claim/token ID'), ); export const erc20TokenOption = Options.text('token').pipe( diff --git a/src/cli/approve/validation.ts b/src/cli/approve/validation.ts index 22ec8ce..7cbd646 100644 --- a/src/cli/approve/validation.ts +++ b/src/cli/approve/validation.ts @@ -1,6 +1,6 @@ import { Either, Option } from 'effect'; import type { InvalidAddressError, InvalidAmountError, InvalidChainError } from '../../domain/errors.js'; -import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams } from '../../domain/types/approve.js'; +import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams, type TransferNftParams } from '../../domain/types/approve.js'; import { validateAddress, validateAmount, validateChainId } from '../../domain/validation/eth.js'; export class InvalidApprovalTypeError { @@ -60,6 +60,21 @@ export const validateApproveNftParams = ( }; }); +export const validateTransferNftParams = ( + chain: Option.Option, + from: string, + to: string, + claimId: string, +): Either.Either => + Either.gen(function* () { + return { + chainId: yield* validateChainId(chain), + from: yield* validateAddress(from), + to: yield* validateAddress(to), + claimId: yield* validateAmount(claimId), + }; + }); + export const validateApproveErc20Params = ( chain: Option.Option, token: string, diff --git a/src/cli/frendlend/commands.ts b/src/cli/frendlend/commands.ts index ecc6d9a..a7fb1a3 100644 --- a/src/cli/frendlend/commands.ts +++ b/src/cli/frendlend/commands.ts @@ -1,6 +1,6 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; -import { buildApproveNft } from '../../application/services/approve-service.js'; +import { buildApproveNft, buildTransferNft, getFrendLendControllerAddress } from '../../application/services/approve-service.js'; import { buildAcceptLoan, buildImpairLoan, @@ -36,8 +36,8 @@ import { paymentAmountOption, periodsPerYearOption, } from '../options/invoice-options.js'; -import { approveClaimIdOption, approveToOption } from '../approve/options.js'; -import { validateApproveNftParams } from '../approve/validation.js'; +import { approveClaimIdOption, approveFromOption, approveToOption } from '../approve/options.js'; +import { validateApproveNftParams, validateTransferNftParams } from '../approve/validation.js'; import { privateKeyOption, tokenOption } from '../options/pay-options.js'; import { validateAcceptLoanParams, @@ -455,7 +455,7 @@ export const frendlendApproveNftBuildCommand = Command.make( ({ chain, to, claimId, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params); + const tx = yield* buildApproveNft(params, getFrendLendControllerAddress); yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); }), ).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction for a loan claim NFT')); @@ -473,7 +473,7 @@ export const frendlendApproveNftExecuteCommand = Command.make( ({ chain, to, claimId, privateKey, rpcUrl, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params); + const tx = yield* buildApproveNft(params, getFrendLendControllerAddress); const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); yield* Console.log(formatResult(result, format as OutputFormat)); @@ -481,10 +481,57 @@ export const frendlendApproveNftExecuteCommand = Command.make( ).pipe(Command.withDescription('Sign and send an ERC721 approve transaction for a loan claim NFT')); export const frendlendApproveNftCommand = Command.make('approve-nft', {}).pipe( - Command.withDescription('Approve an address for a specific loan claim NFT (BullaClaimV2)'), + Command.withDescription('Approve an address for a specific loan claim NFT (via BullaFrendLendV2 controller)'), Command.withSubcommands([frendlendApproveNftBuildCommand, frendlendApproveNftExecuteCommand]), ); +// ============================================================================ +// TRANSFER NFT (ERC721 transferFrom via BullaFrendLendV2 controller) +// ============================================================================ + +export const frendlendTransferNftBuildCommand = Command.make( + 'build', + { + chain: chainOption, + from: approveFromOption, + to: approveToOption, + claimId: approveClaimIdOption, + format: formatOption, + }, + ({ chain, from, to, claimId, format }) => + Effect.gen(function* () { + const params = yield* validateTransferNftParams(chain, from, to, claimId); + const tx = yield* buildTransferNft(params, getFrendLendControllerAddress); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned ERC721 transferFrom transaction for a loan claim NFT')); + +export const frendlendTransferNftExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + from: approveFromOption, + to: approveToOption, + claimId: approveClaimIdOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, from, to, claimId, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateTransferNftParams(chain, from, to, claimId); + const tx = yield* buildTransferNft(params, getFrendLendControllerAddress); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an ERC721 transferFrom transaction for a loan claim NFT')); + +export const frendlendTransferNftCommand = Command.make('transfer-nft', {}).pipe( + Command.withDescription('Transfer a loan claim NFT to another address (via BullaFrendLendV2 controller)'), + Command.withSubcommands([frendlendTransferNftBuildCommand, frendlendTransferNftExecuteCommand]), +); + // ============================================================================ // EXPORT ALL FRENDLEND COMMANDS // ============================================================================ @@ -498,4 +545,5 @@ export const frendlendCommands = [ frendlendMarkPaidCommand, frendlendSetCallbackCommand, frendlendApproveNftCommand, + frendlendTransferNftCommand, ] as const; diff --git a/src/cli/invoice/commands.ts b/src/cli/invoice/commands.ts index 6c12060..89a573b 100644 --- a/src/cli/invoice/commands.ts +++ b/src/cli/invoice/commands.ts @@ -1,6 +1,6 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; -import { buildApproveNft } from '../../application/services/approve-service.js'; +import { buildApproveNft, buildTransferNft, getInvoiceControllerAddress } from '../../application/services/approve-service.js'; import { buildAcceptPurchaseOrder, buildCancelInvoice, @@ -35,8 +35,8 @@ import { paymentAmountOption, periodsPerYearOption, } from '../options/invoice-options.js'; -import { approveClaimIdOption, approveToOption } from '../approve/options.js'; -import { validateApproveNftParams } from '../approve/validation.js'; +import { approveClaimIdOption, approveFromOption, approveToOption } from '../approve/options.js'; +import { validateApproveNftParams, validateTransferNftParams } from '../approve/validation.js'; import { privateKeyOption, tokenOption } from '../options/pay-options.js'; import { validateAcceptPurchaseOrderParams, @@ -550,7 +550,7 @@ export const invoiceApproveNftBuildCommand = Command.make( ({ chain, to, claimId, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params); + const tx = yield* buildApproveNft(params, getInvoiceControllerAddress); yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); }), ).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction for an invoice claim NFT')); @@ -568,7 +568,7 @@ export const invoiceApproveNftExecuteCommand = Command.make( ({ chain, to, claimId, privateKey, rpcUrl, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params); + const tx = yield* buildApproveNft(params, getInvoiceControllerAddress); const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); yield* Console.log(formatResult(result, format as OutputFormat)); @@ -576,10 +576,57 @@ export const invoiceApproveNftExecuteCommand = Command.make( ).pipe(Command.withDescription('Sign and send an ERC721 approve transaction for an invoice claim NFT')); export const invoiceApproveNftCommand = Command.make('approve-nft', {}).pipe( - Command.withDescription('Approve an address for a specific invoice claim NFT (BullaClaimV2)'), + Command.withDescription('Approve an address for a specific invoice claim NFT (via BullaInvoice controller)'), Command.withSubcommands([invoiceApproveNftBuildCommand, invoiceApproveNftExecuteCommand]), ); +// ============================================================================ +// TRANSFER NFT (ERC721 transferFrom via BullaInvoice controller) +// ============================================================================ + +export const invoiceTransferNftBuildCommand = Command.make( + 'build', + { + chain: chainOption, + from: approveFromOption, + to: approveToOption, + claimId: approveClaimIdOption, + format: formatOption, + }, + ({ chain, from, to, claimId, format }) => + Effect.gen(function* () { + const params = yield* validateTransferNftParams(chain, from, to, claimId); + const tx = yield* buildTransferNft(params, getInvoiceControllerAddress); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }), +).pipe(Command.withDescription('Build an unsigned ERC721 transferFrom transaction for an invoice claim NFT')); + +export const invoiceTransferNftExecuteCommand = Command.make( + 'execute', + { + chain: chainOption, + from: approveFromOption, + to: approveToOption, + claimId: approveClaimIdOption, + privateKey: privateKeyOption, + rpcUrl: rpcUrlOption, + format: formatOption, + }, + ({ chain, from, to, claimId, privateKey, rpcUrl, format }) => + Effect.gen(function* () { + const params = yield* validateTransferNftParams(chain, from, to, claimId); + const tx = yield* buildTransferNft(params, getInvoiceControllerAddress); + const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); + const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); + yield* Console.log(formatResult(result, format as OutputFormat)); + }), +).pipe(Command.withDescription('Sign and send an ERC721 transferFrom transaction for an invoice claim NFT')); + +export const invoiceTransferNftCommand = Command.make('transfer-nft', {}).pipe( + Command.withDescription('Transfer an invoice claim NFT to another address (via BullaInvoice controller)'), + Command.withSubcommands([invoiceTransferNftBuildCommand, invoiceTransferNftExecuteCommand]), +); + // ============================================================================ // EXPORT ALL INVOICE COMMANDS // ============================================================================ @@ -595,4 +642,5 @@ export const invoiceCommands = [ invoiceAcceptPoCommand, invoiceDeliverPoCommand, invoiceApproveNftCommand, + invoiceTransferNftCommand, ] as const; diff --git a/src/domain/types/approve.ts b/src/domain/types/approve.ts index 6be8221..3e8174a 100644 --- a/src/domain/types/approve.ts +++ b/src/domain/types/approve.ts @@ -21,6 +21,13 @@ export interface ApproveNftParams { claimId: bigint; } +export interface TransferNftParams { + chainId: ChainId; + from: EthAddress; + to: EthAddress; + claimId: bigint; +} + export interface ApproveErc20Params { chainId: ChainId; token: EthAddress; diff --git a/src/infrastructure/abi/bulla-claim-v2.ts b/src/infrastructure/abi/bulla-claim-v2.ts index 935de9d..37f23f3 100644 --- a/src/infrastructure/abi/bulla-claim-v2.ts +++ b/src/infrastructure/abi/bulla-claim-v2.ts @@ -1,4 +1,4 @@ -/** BullaClaimV2 (ERC721) ABI — only the approve function. */ +/** BullaClaimV2 (ERC721) ABI — approve and transferFrom functions. */ export const bullaClaimV2Abi = [ { inputs: [ @@ -10,4 +10,22 @@ export const bullaClaimV2Abi = [ stateMutability: 'nonpayable', type: 'function', }, + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'claimId', type: 'uint256' }], + name: 'ownerOf', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, ] as const; diff --git a/src/infrastructure/encoding/viem-approve-encoder.ts b/src/infrastructure/encoding/viem-approve-encoder.ts index 7b96c41..305b163 100644 --- a/src/infrastructure/encoding/viem-approve-encoder.ts +++ b/src/infrastructure/encoding/viem-approve-encoder.ts @@ -2,7 +2,7 @@ import { Effect, Layer } from 'effect'; import { encodeFunctionData } from 'viem'; import { ApproveEncoderService } from '../../application/ports/approve-encoder-port.js'; import type { Hex } from '../../domain/types/eth.js'; -import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams } from '../../domain/types/approve.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams, TransferNftParams } from '../../domain/types/approve.js'; import { bullaApprovalRegistryAbi } from '../abi/bulla-approval-registry.js'; import { bullaClaimV2Abi } from '../abi/bulla-claim-v2.js'; import { erc20Abi } from '../abi/erc20.js'; @@ -34,8 +34,18 @@ const encodeApproveErc20 = (params: Omit): Effect.Effect => + Effect.sync(() => + encodeFunctionData({ + abi: bullaClaimV2Abi, + functionName: 'transferFrom', + args: [params.from, params.to, params.claimId], + }), + ); + export const ViemApproveEncoderLive = Layer.succeed(ApproveEncoderService, { encodeApproveCreateClaim, encodeApproveNft, encodeApproveErc20, + encodeTransferNft, }); diff --git a/test/application/services/approve-service.test.ts b/test/application/services/approve-service.test.ts index 76fdf3b..96e3cef 100644 --- a/test/application/services/approve-service.test.ts +++ b/test/application/services/approve-service.test.ts @@ -7,8 +7,12 @@ import { buildApproveCreateClaim, buildApproveErc20, buildApproveNft, + buildTransferNft, + getClaimContractAddress, + getInvoiceControllerAddress, + getFrendLendControllerAddress, } from '../../../src/application/services/approve-service.js'; -import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams } from '../../../src/domain/types/approve.js'; +import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams, type TransferNftParams } from '../../../src/domain/types/approve.js'; import type { ChainId, EthAddress, Hex } from '../../../src/domain/types/eth.js'; import { bullaApprovalRegistryAbi } from '../../../src/infrastructure/abi/bulla-approval-registry.js'; import { bullaClaimV2Abi } from '../../../src/infrastructure/abi/bulla-claim-v2.js'; @@ -16,6 +20,8 @@ import { erc20Abi } from '../../../src/infrastructure/abi/erc20.js'; const APPROVAL_REGISTRY_ADDRESS = '0xb1F9a06D72F8737B4fcf4550f1C8EA769772Ad76' as EthAddress; const CLAIM_V2_ADDRESS = '0x0d9EF9d436fF341E500360a6B5E5750aB85BCCB6' as EthAddress; +const INVOICE_ADDRESS = '0xa2c4B7239A0d179A923751cC75277fe139AB092F' as EthAddress; +const FRENDLEND_ADDRESS = '0x4d6A66D32CF34270e4cc9C9F201CA4dB650Be3f2' as EthAddress; const CONTROLLER = '0xa2c4B7239A0d179A923751cC75277fe139AB092F' as EthAddress; const SPENDER = '0x1234567890abcdef1234567890abcdef12345678' as EthAddress; const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as EthAddress; @@ -36,6 +42,14 @@ const makeApproveNftParams = (overrides: Partial = {}): Approv ...overrides, }); +const makeTransferNftParams = (overrides: Partial = {}): TransferNftParams => ({ + chainId: 11155111 as ChainId, + from: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' as EthAddress, + to: SPENDER, + claimId: 42n, + ...overrides, +}); + const makeApproveErc20Params = (overrides: Partial = {}): ApproveErc20Params => ({ chainId: 11155111 as ChainId, token: TOKEN, @@ -48,8 +62,8 @@ const makeApproveErc20Params = (overrides: Partial = {}): Ap const TestRegistryService = Layer.succeed(RegistryService, { getInstantPaymentAddress: () => Effect.succeed('0x0000000000000000000000000000000000000000' as EthAddress), - getInvoiceAddress: () => Effect.succeed('0x0000000000000000000000000000000000000000' as EthAddress), - getFrendLendAddress: () => Effect.succeed('0x0000000000000000000000000000000000000000' as EthAddress), + getInvoiceAddress: () => Effect.succeed(INVOICE_ADDRESS), + getFrendLendAddress: () => Effect.succeed(FRENDLEND_ADDRESS), getApprovalRegistryAddress: () => Effect.succeed(APPROVAL_REGISTRY_ADDRESS), getClaimAddress: () => Effect.succeed(CLAIM_V2_ADDRESS), validateFactoringPool: () => Effect.succeed(true), @@ -85,6 +99,14 @@ const TestApproveEncoder = Layer.succeed(ApproveEncoderService, { args: [params.spender as `0x${string}`, params.amount], }) as Hex, ), + encodeTransferNft: params => + Effect.succeed( + encodeFunctionData({ + abi: bullaClaimV2Abi, + functionName: 'transferFrom', + args: [params.from as `0x${string}`, params.to as `0x${string}`, params.claimId], + }) as Hex, + ), }); const BuildTestLayers = Layer.mergeAll(TestRegistryService, TestApproveEncoder); @@ -130,9 +152,25 @@ describe('buildApproveCreateClaim', () => { }); describe('buildApproveNft', () => { - it('produces an unsigned transaction targeting the claim contract', async () => { + it('targets invoice controller for invoice claims', async () => { + const params = makeApproveNftParams(); + const result = await Effect.runPromise(buildApproveNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(INVOICE_ADDRESS); + expect(result.operation).toBe(0); + }); + + it('targets frendlend controller for loan claims', async () => { + const params = makeApproveNftParams(); + const result = await Effect.runPromise(buildApproveNft(params, getFrendLendControllerAddress).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(FRENDLEND_ADDRESS); + expect(result.operation).toBe(0); + }); + + it('targets claim contract for uncontrolled claims', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params).pipe(Effect.provide(BuildTestLayers))); + const result = await Effect.runPromise(buildApproveNft(params, getClaimContractAddress).pipe(Effect.provide(BuildTestLayers))); expect(result.to).toBe(CLAIM_V2_ADDRESS); expect(result.operation).toBe(0); @@ -140,14 +178,14 @@ describe('buildApproveNft', () => { it('sets value to "0"', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params).pipe(Effect.provide(BuildTestLayers))); + const result = await Effect.runPromise(buildApproveNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); expect(result.value).toBe('0'); }); it('encodes calldata starting with the approve function selector', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params).pipe(Effect.provide(BuildTestLayers))); + const result = await Effect.runPromise(buildApproveNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); expect(result.data).toMatch(/^0x[0-9a-f]{8}/); expect(result.data.length).toBeGreaterThan(10); @@ -186,3 +224,36 @@ describe('buildApproveErc20', () => { expect(result.to).toBe(customToken); }); }); + +describe('buildTransferNft', () => { + it('targets invoice controller for invoice claims', async () => { + const params = makeTransferNftParams(); + const result = await Effect.runPromise(buildTransferNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(INVOICE_ADDRESS); + expect(result.operation).toBe(0); + }); + + it('targets frendlend controller for loan claims', async () => { + const params = makeTransferNftParams(); + const result = await Effect.runPromise(buildTransferNft(params, getFrendLendControllerAddress).pipe(Effect.provide(BuildTestLayers))); + + expect(result.to).toBe(FRENDLEND_ADDRESS); + expect(result.operation).toBe(0); + }); + + it('encodes calldata starting with the transferFrom function selector', async () => { + const params = makeTransferNftParams(); + const result = await Effect.runPromise(buildTransferNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + + expect(result.data).toMatch(/^0x[0-9a-f]{8}/); + expect(result.data.length).toBeGreaterThan(10); + }); + + it('sets value to "0"', async () => { + const params = makeTransferNftParams(); + const result = await Effect.runPromise(buildTransferNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + + expect(result.value).toBe('0'); + }); +}); diff --git a/test/e2e/approve.e2e.test.ts b/test/e2e/approve.e2e.test.ts index efafe9c..48c6210 100644 --- a/test/e2e/approve.e2e.test.ts +++ b/test/e2e/approve.e2e.test.ts @@ -1,6 +1,25 @@ -import { describe, expect, it } from 'vitest'; -import { runCli } from './helpers/cli-runner.js'; -import { ANVIL_ACCOUNTS, SEPOLIA_CHAIN_ID } from './setup/constants.js'; +import { createPublicClient, http, parseAbi } from 'viem'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runCli, runCliExecute } from './helpers/cli-runner.js'; +import { approveCreateClaim, WETH_ADDRESS, wrapEthAndApprove } from './helpers/erc20-setup.js'; +import { getNewTokenIdFromReceipt, getOfferIdFromReceipt } from './helpers/receipt-parser.js'; +import { type AnvilInstance, startAnvil } from './setup/anvil.js'; +import { ANVIL_ACCOUNTS, CONTRACTS, SEPOLIA_CHAIN_ID } from './setup/constants.js'; + +const ownerOfAbi = parseAbi(['function ownerOf(uint256 tokenId) view returns (address)']); + +/** Sepolia BullaClaimV2 address (from generated registry) */ +const BULLA_CLAIM_V2 = '0x0d9EF9d436fF341E500360a6B5E5750aB85BCCB6' as const; + +async function getOwnerOf(rpcUrl: string, claimId: bigint): Promise { + const client = createPublicClient({ transport: http(rpcUrl) }); + return client.readContract({ + address: BULLA_CLAIM_V2, + abi: ownerOfAbi, + functionName: 'ownerOf', + args: [claimId], + }); +} describe('bulla approve build (e2e)', () => { it('approve create-claim build outputs valid JSON transaction', () => { @@ -83,64 +102,235 @@ describe('bulla approve build (e2e)', () => { }); }); -describe('approve-nft via invoice and frendlend (e2e)', () => { - it('invoice approve-nft build outputs valid JSON transaction', () => { - const result = runCli([ - 'invoice', - 'approve-nft', - 'build', - '--chain', - String(SEPOLIA_CHAIN_ID), - '--to', - ANVIL_ACCOUNTS.account1.address, - '--claim-id', - '42', - '--format', - 'json', - ]); +const forkUrl = process.env.SEPOLIA_RPC_URL; - expect(result.exitCode).toBe(0); - const tx = JSON.parse(result.stdout); - expect(tx.to).toMatch(/^0x[0-9a-fA-F]{40}$/); - expect(tx.data).toMatch(/^0x[0-9a-f]+$/); - expect(tx.value).toBe('0'); - expect(tx.operation).toBe(0); +describe.skipIf(!forkUrl)('invoice: approve-nft -> transfer-nft lifecycle (e2e)', () => { + let anvil: AnvilInstance; + + beforeAll(async () => { + anvil = await startAnvil(forkUrl!); + + // Approve both accounts on the BullaApprovalRegistry for the invoice contract + await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account0.privateKey as `0x${string}`, CONTRACTS.bullaInvoice); + await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account1.privateKey as `0x${string}`, CONTRACTS.bullaInvoice); }); - it('frendlend approve-nft build outputs valid JSON transaction', () => { - const result = runCli([ - 'frendlend', - 'approve-nft', - 'build', - '--chain', - String(SEPOLIA_CHAIN_ID), - '--to', - ANVIL_ACCOUNTS.account1.address, - '--claim-id', - '42', - '--format', - 'json', - ]); + afterAll(() => { + anvil?.stop(); + }); - expect(result.exitCode).toBe(0); - const tx = JSON.parse(result.stdout); - expect(tx.to).toMatch(/^0x[0-9a-fA-F]{40}$/); - expect(tx.data).toMatch(/^0x[0-9a-f]+$/); - expect(tx.value).toBe('0'); - expect(tx.operation).toBe(0); + describe('mint invoice -> approve transfer -> transfer -> check ownership', () => { + let claimId: bigint; + + it('creates an invoice (mint)', async () => { + const result = runCliExecute([ + 'invoice', + 'create', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--debtor', + ANVIL_ACCOUNTS.account1.address, + '--creditor', + ANVIL_ACCOUNTS.account0.address, + '--amount', + '1000000000000000000', + '--token', + WETH_ADDRESS, + '--description', + 'approve-transfer e2e test', + '--private-key', + ANVIL_ACCOUNTS.account0.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + claimId = await getNewTokenIdFromReceipt(anvil.rpcUrl, result.txHash as `0x${string}`); + expect(claimId).toBeGreaterThan(0n); + + // Verify initial owner is account0 (creditor) + const owner = await getOwnerOf(anvil.rpcUrl, claimId); + expect(owner.toLowerCase()).toBe(ANVIL_ACCOUNTS.account0.address.toLowerCase()); + }); + + it('approves account1 to transfer the invoice NFT', () => { + const result = runCliExecute([ + 'invoice', + 'approve-nft', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--to', + ANVIL_ACCOUNTS.account1.address, + '--claim-id', + String(claimId), + '--private-key', + ANVIL_ACCOUNTS.account0.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('transfers the invoice NFT from account0 to account1', () => { + const result = runCliExecute([ + 'invoice', + 'transfer-nft', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--from', + ANVIL_ACCOUNTS.account0.address, + '--to', + ANVIL_ACCOUNTS.account1.address, + '--claim-id', + String(claimId), + '--private-key', + ANVIL_ACCOUNTS.account1.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('verifies the NFT owner has changed to account1', async () => { + const owner = await getOwnerOf(anvil.rpcUrl, claimId); + expect(owner.toLowerCase()).toBe(ANVIL_ACCOUNTS.account1.address.toLowerCase()); + }); }); +}); - it('invoice --help shows approve-nft subcommand', () => { - const result = runCli(['invoice', '--help']); +describe.skipIf(!forkUrl)('frendlend: approve-nft -> transfer-nft lifecycle (e2e)', () => { + let anvil: AnvilInstance; - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('approve-nft'); + beforeAll(async () => { + anvil = await startAnvil(forkUrl!); + + // Approve both accounts on the BullaApprovalRegistry for the frendlend contract + await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account0.privateKey as `0x${string}`, CONTRACTS.frendLendV2); + await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account1.privateKey as `0x${string}`, CONTRACTS.frendLendV2); + + // Fund account0 (lender) with WETH and approve the frendlend contract to spend it + await wrapEthAndApprove( + anvil.rpcUrl, + ANVIL_ACCOUNTS.account0.privateKey as `0x${string}`, + CONTRACTS.frendLendV2, + 10_000_000_000_000_000_000n, + ); }); - it('frendlend --help shows approve-nft subcommand', () => { - const result = runCli(['frendlend', '--help']); + afterAll(() => { + anvil?.stop(); + }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('approve-nft'); + describe('offer loan -> accept loan -> approve transfer -> transfer -> check ownership', () => { + let offerId: bigint; + let claimId: bigint; + + it('offers a loan', async () => { + const result = runCliExecute([ + 'frendlend', + 'offer-loan', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--debtor', + ANVIL_ACCOUNTS.account1.address, + '--creditor', + ANVIL_ACCOUNTS.account0.address, + '--amount', + '1000000000000000000', + '--token', + WETH_ADDRESS, + '--description', + 'frendlend approve-transfer e2e', + '--interest-rate-bps', + '500', + '--term-length', + '2592000', + '--private-key', + ANVIL_ACCOUNTS.account0.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + offerId = await getOfferIdFromReceipt(anvil.rpcUrl, result.txHash as `0x${string}`); + expect(offerId).toBeGreaterThan(0n); + }); + + it('accepts the loan', async () => { + const result = runCliExecute([ + 'frendlend', + 'accept-loan', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--offer-id', + String(offerId), + '--private-key', + ANVIL_ACCOUNTS.account1.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + claimId = await getNewTokenIdFromReceipt(anvil.rpcUrl, result.txHash as `0x${string}`); + expect(claimId).toBeGreaterThan(0n); + + // Verify initial owner is account0 (creditor/lender) + const owner = await getOwnerOf(anvil.rpcUrl, claimId); + expect(owner.toLowerCase()).toBe(ANVIL_ACCOUNTS.account0.address.toLowerCase()); + }); + + it('approves account1 to transfer the loan NFT', () => { + const result = runCliExecute([ + 'frendlend', + 'approve-nft', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--to', + ANVIL_ACCOUNTS.account1.address, + '--claim-id', + String(claimId), + '--private-key', + ANVIL_ACCOUNTS.account0.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('transfers the loan NFT from account0 to account1', () => { + const result = runCliExecute([ + 'frendlend', + 'transfer-nft', + 'execute', + '--chain', + String(SEPOLIA_CHAIN_ID), + '--from', + ANVIL_ACCOUNTS.account0.address, + '--to', + ANVIL_ACCOUNTS.account1.address, + '--claim-id', + String(claimId), + '--private-key', + ANVIL_ACCOUNTS.account1.privateKey, + '--rpc-url', + anvil.rpcUrl, + ]); + + expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('verifies the NFT owner has changed to account1', async () => { + const owner = await getOwnerOf(anvil.rpcUrl, claimId); + expect(owner.toLowerCase()).toBe(ANVIL_ACCOUNTS.account1.address.toLowerCase()); + }); }); }); From 3872c8dfc8f3ab9c09cc3db9ce791288deddc4f0 Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 15:54:36 -0400 Subject: [PATCH 5/9] fix: use public Sepolia RPC fallback for e2e tests Uses publicnode.com Sepolia RPC as default when SEPOLIA_RPC_URL env var is not set, so approve/transfer lifecycle tests always run. Co-Authored-By: Claude Opus 4.6 --- test/e2e/approve.e2e.test.ts | 12 +++++------- test/e2e/setup/constants.ts | 2 ++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/e2e/approve.e2e.test.ts b/test/e2e/approve.e2e.test.ts index 48c6210..fa37abb 100644 --- a/test/e2e/approve.e2e.test.ts +++ b/test/e2e/approve.e2e.test.ts @@ -4,7 +4,7 @@ import { runCli, runCliExecute } from './helpers/cli-runner.js'; import { approveCreateClaim, WETH_ADDRESS, wrapEthAndApprove } from './helpers/erc20-setup.js'; import { getNewTokenIdFromReceipt, getOfferIdFromReceipt } from './helpers/receipt-parser.js'; import { type AnvilInstance, startAnvil } from './setup/anvil.js'; -import { ANVIL_ACCOUNTS, CONTRACTS, SEPOLIA_CHAIN_ID } from './setup/constants.js'; +import { ANVIL_ACCOUNTS, CONTRACTS, SEPOLIA_CHAIN_ID, SEPOLIA_RPC_URL } from './setup/constants.js'; const ownerOfAbi = parseAbi(['function ownerOf(uint256 tokenId) view returns (address)']); @@ -102,13 +102,11 @@ describe('bulla approve build (e2e)', () => { }); }); -const forkUrl = process.env.SEPOLIA_RPC_URL; - -describe.skipIf(!forkUrl)('invoice: approve-nft -> transfer-nft lifecycle (e2e)', () => { +describe('invoice: approve-nft -> transfer-nft lifecycle (e2e)', () => { let anvil: AnvilInstance; beforeAll(async () => { - anvil = await startAnvil(forkUrl!); + anvil = await startAnvil(SEPOLIA_RPC_URL); // Approve both accounts on the BullaApprovalRegistry for the invoice contract await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account0.privateKey as `0x${string}`, CONTRACTS.bullaInvoice); @@ -203,11 +201,11 @@ describe.skipIf(!forkUrl)('invoice: approve-nft -> transfer-nft lifecycle (e2e)' }); }); -describe.skipIf(!forkUrl)('frendlend: approve-nft -> transfer-nft lifecycle (e2e)', () => { +describe('frendlend: approve-nft -> transfer-nft lifecycle (e2e)', () => { let anvil: AnvilInstance; beforeAll(async () => { - anvil = await startAnvil(forkUrl!); + anvil = await startAnvil(SEPOLIA_RPC_URL); // Approve both accounts on the BullaApprovalRegistry for the frendlend contract await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account0.privateKey as `0x${string}`, CONTRACTS.frendLendV2); diff --git a/test/e2e/setup/constants.ts b/test/e2e/setup/constants.ts index 6776578..c48e86e 100644 --- a/test/e2e/setup/constants.ts +++ b/test/e2e/setup/constants.ts @@ -13,6 +13,8 @@ export const ANVIL_ACCOUNTS = { export const SEPOLIA_CHAIN_ID = 11155111; +export const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com'; + export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; // Sepolia contract addresses (from generated registry) From bfa11da3c934d2de803c6e7af4891d08953adf32 Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 16:20:33 -0400 Subject: [PATCH 6/9] refactor: extract NftTransferService, use safeTransferFrom - Create NftTransferService as Effect Context service with buildApproveNft and buildTransferNft methods - Use Layer.effect factory (makeNftTransferServiceLayer) to create controller-specific service instances (Invoice, FrendLend, Claim) - Provide NftTransferService via pipe in invoice/frendlend commands - Switch from transferFrom to safeTransferFrom for NFT transfers - Clean up approve-service.ts to only contain create-claim and ERC20 Co-Authored-By: Claude Opus 4.6 --- src/application/ports/nft-transfer-port.ts | 15 +++++ src/application/services/approve-service.ts | 61 +----------------- .../services/nft-transfer-service.ts | 63 +++++++++++++++++++ src/cli/frendlend/commands.ts | 27 ++++---- src/cli/invoice/commands.ts | 27 ++++---- src/infrastructure/abi/bulla-claim-v2.ts | 4 +- .../encoding/viem-approve-encoder.ts | 2 +- .../services/approve-service.test.ts | 53 +++++++++------- 8 files changed, 146 insertions(+), 106 deletions(-) create mode 100644 src/application/ports/nft-transfer-port.ts create mode 100644 src/application/services/nft-transfer-service.ts diff --git a/src/application/ports/nft-transfer-port.ts b/src/application/ports/nft-transfer-port.ts new file mode 100644 index 0000000..c5ccf5a --- /dev/null +++ b/src/application/ports/nft-transfer-port.ts @@ -0,0 +1,15 @@ +import { Context, Effect } from 'effect'; +import type { ContractNotFoundError, UnsupportedChainError } from '../../domain/errors.js'; +import type { ApproveNftParams, TransferNftParams } from '../../domain/types/approve.js'; +import type { UnsignedTransaction } from '../../domain/types/transaction.js'; + +export interface NftTransferService { + readonly buildApproveNft: ( + params: ApproveNftParams, + ) => Effect.Effect; + readonly buildTransferNft: ( + params: TransferNftParams, + ) => Effect.Effect; +} + +export const NftTransferService = Context.GenericTag('@services/NftTransferService'); diff --git a/src/application/services/approve-service.ts b/src/application/services/approve-service.ts index 188de00..a9f2d81 100644 --- a/src/application/services/approve-service.ts +++ b/src/application/services/approve-service.ts @@ -1,7 +1,6 @@ import { Effect } from 'effect'; import type { ContractNotFoundError, UnsupportedChainError } from '../../domain/errors.js'; -import type { ChainId, EthAddress } from '../../domain/types/eth.js'; -import type { ApproveCreateClaimParams, ApproveErc20Params, ApproveNftParams, TransferNftParams } from '../../domain/types/approve.js'; +import type { ApproveCreateClaimParams, ApproveErc20Params } from '../../domain/types/approve.js'; import type { UnsignedTransaction } from '../../domain/types/transaction.js'; import { ApproveEncoderService } from '../ports/approve-encoder-port.js'; import { RegistryService } from '../ports/registry-port.js'; @@ -24,64 +23,6 @@ export const buildApproveCreateClaim = ( }; }); -/** - * Build an ERC721 approve transaction targeting a specific contract. - * For controlled claims, the target is the controller contract (BullaInvoice, BullaFrendLendV2). - * For uncontrolled claims, the target is BullaClaimV2 directly. - */ -export const buildApproveNft = ( - params: ApproveNftParams, - getContractAddress: (chainId: ChainId) => Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const encoder = yield* ApproveEncoderService; - - const contractAddress = yield* getContractAddress(params.chainId); - const data = yield* encoder.encodeApproveNft(params); - - return { - to: contractAddress, - value: '0', - data, - operation: 0 as const, - }; - }); - -/** Registry lookup for invoice controller address */ -export const getInvoiceControllerAddress = (chainId: ChainId) => - RegistryService.pipe(Effect.flatMap(r => r.getInvoiceAddress(chainId))); - -/** Registry lookup for frendlend controller address */ -export const getFrendLendControllerAddress = (chainId: ChainId) => - RegistryService.pipe(Effect.flatMap(r => r.getFrendLendAddress(chainId))); - -/** Registry lookup for BullaClaimV2 address (uncontrolled claims) */ -export const getClaimContractAddress = (chainId: ChainId) => - RegistryService.pipe(Effect.flatMap(r => r.getClaimAddress(chainId))); - -/** - * Build an ERC721 transferFrom transaction targeting a specific contract. - * For controlled claims, the target is the controller contract. - * For uncontrolled claims, the target is BullaClaimV2 directly. - */ -export const buildTransferNft = ( - params: TransferNftParams, - getContractAddress: (chainId: ChainId) => Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const encoder = yield* ApproveEncoderService; - - const contractAddress = yield* getContractAddress(params.chainId); - const data = yield* encoder.encodeTransferNft(params); - - return { - to: contractAddress, - value: '0', - data, - operation: 0 as const, - }; - }); - export const buildApproveErc20 = ( params: ApproveErc20Params, ): Effect.Effect => diff --git a/src/application/services/nft-transfer-service.ts b/src/application/services/nft-transfer-service.ts new file mode 100644 index 0000000..b7bd287 --- /dev/null +++ b/src/application/services/nft-transfer-service.ts @@ -0,0 +1,63 @@ +import { Effect, Layer } from 'effect'; +import type { ContractNotFoundError, UnsupportedChainError } from '../../domain/errors.js'; +import type { ChainId, EthAddress } from '../../domain/types/eth.js'; +import type { ApproveNftParams, TransferNftParams } from '../../domain/types/approve.js'; +import type { UnsignedTransaction } from '../../domain/types/transaction.js'; +import { ApproveEncoderService } from '../ports/approve-encoder-port.js'; +import { NftTransferService } from '../ports/nft-transfer-port.js'; +import { RegistryService } from '../ports/registry-port.js'; + +/** + * Create an NftTransferService layer that targets a specific controller contract. + * For invoice claims, pass registry => registry.getInvoiceAddress. + * For frendlend claims, pass registry => registry.getFrendLendAddress. + * For uncontrolled claims, pass registry => registry.getClaimAddress. + */ +export const makeNftTransferServiceLayer = ( + getContractAddress: ( + registry: RegistryService, + chainId: ChainId, + ) => Effect.Effect, +) => + Layer.effect( + NftTransferService, + Effect.gen(function* () { + const registry = yield* RegistryService; + const encoder = yield* ApproveEncoderService; + + return { + buildApproveNft: ( + params: ApproveNftParams, + ): Effect.Effect => + Effect.gen(function* () { + const contractAddress = yield* getContractAddress(registry, params.chainId); + const data = yield* encoder.encodeApproveNft(params); + return { to: contractAddress, value: '0', data, operation: 0 as const }; + }), + + buildTransferNft: ( + params: TransferNftParams, + ): Effect.Effect => + Effect.gen(function* () { + const contractAddress = yield* getContractAddress(registry, params.chainId); + const data = yield* encoder.encodeTransferNft(params); + return { to: contractAddress, value: '0', data, operation: 0 as const }; + }), + }; + }), + ); + +/** NftTransferService targeting BullaInvoice controller */ +export const InvoiceNftTransferServiceLive = makeNftTransferServiceLayer((registry, chainId) => + registry.getInvoiceAddress(chainId), +); + +/** NftTransferService targeting BullaFrendLendV2 controller */ +export const FrendLendNftTransferServiceLive = makeNftTransferServiceLayer((registry, chainId) => + registry.getFrendLendAddress(chainId), +); + +/** NftTransferService targeting BullaClaimV2 (uncontrolled claims) */ +export const ClaimNftTransferServiceLive = makeNftTransferServiceLayer((registry, chainId) => + registry.getClaimAddress(chainId), +); diff --git a/src/cli/frendlend/commands.ts b/src/cli/frendlend/commands.ts index a7fb1a3..3641dda 100644 --- a/src/cli/frendlend/commands.ts +++ b/src/cli/frendlend/commands.ts @@ -1,6 +1,7 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; -import { buildApproveNft, buildTransferNft, getFrendLendControllerAddress } from '../../application/services/approve-service.js'; +import { NftTransferService } from '../../application/ports/nft-transfer-port.js'; +import { FrendLendNftTransferServiceLive } from '../../application/services/nft-transfer-service.js'; import { buildAcceptLoan, buildImpairLoan, @@ -455,9 +456,10 @@ export const frendlendApproveNftBuildCommand = Command.make( ({ chain, to, claimId, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params, getFrendLendControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildApproveNft(params); yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); - }), + }).pipe(Effect.provide(FrendLendNftTransferServiceLive)), ).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction for a loan claim NFT')); export const frendlendApproveNftExecuteCommand = Command.make( @@ -473,11 +475,12 @@ export const frendlendApproveNftExecuteCommand = Command.make( ({ chain, to, claimId, privateKey, rpcUrl, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params, getFrendLendControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildApproveNft(params); const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); yield* Console.log(formatResult(result, format as OutputFormat)); - }), + }).pipe(Effect.provide(FrendLendNftTransferServiceLive)), ).pipe(Command.withDescription('Sign and send an ERC721 approve transaction for a loan claim NFT')); export const frendlendApproveNftCommand = Command.make('approve-nft', {}).pipe( @@ -501,10 +504,11 @@ export const frendlendTransferNftBuildCommand = Command.make( ({ chain, from, to, claimId, format }) => Effect.gen(function* () { const params = yield* validateTransferNftParams(chain, from, to, claimId); - const tx = yield* buildTransferNft(params, getFrendLendControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildTransferNft(params); yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); - }), -).pipe(Command.withDescription('Build an unsigned ERC721 transferFrom transaction for a loan claim NFT')); + }).pipe(Effect.provide(FrendLendNftTransferServiceLive)), +).pipe(Command.withDescription('Build an unsigned safeTransferFrom transaction for a loan claim NFT')); export const frendlendTransferNftExecuteCommand = Command.make( 'execute', @@ -520,12 +524,13 @@ export const frendlendTransferNftExecuteCommand = Command.make( ({ chain, from, to, claimId, privateKey, rpcUrl, format }) => Effect.gen(function* () { const params = yield* validateTransferNftParams(chain, from, to, claimId); - const tx = yield* buildTransferNft(params, getFrendLendControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildTransferNft(params); const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); yield* Console.log(formatResult(result, format as OutputFormat)); - }), -).pipe(Command.withDescription('Sign and send an ERC721 transferFrom transaction for a loan claim NFT')); + }).pipe(Effect.provide(FrendLendNftTransferServiceLive)), +).pipe(Command.withDescription('Sign and send a safeTransferFrom transaction for a loan claim NFT')); export const frendlendTransferNftCommand = Command.make('transfer-nft', {}).pipe( Command.withDescription('Transfer a loan claim NFT to another address (via BullaFrendLendV2 controller)'), diff --git a/src/cli/invoice/commands.ts b/src/cli/invoice/commands.ts index 89a573b..0f3e0ed 100644 --- a/src/cli/invoice/commands.ts +++ b/src/cli/invoice/commands.ts @@ -1,6 +1,7 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; -import { buildApproveNft, buildTransferNft, getInvoiceControllerAddress } from '../../application/services/approve-service.js'; +import { NftTransferService } from '../../application/ports/nft-transfer-port.js'; +import { InvoiceNftTransferServiceLive } from '../../application/services/nft-transfer-service.js'; import { buildAcceptPurchaseOrder, buildCancelInvoice, @@ -550,9 +551,10 @@ export const invoiceApproveNftBuildCommand = Command.make( ({ chain, to, claimId, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params, getInvoiceControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildApproveNft(params); yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); - }), + }).pipe(Effect.provide(InvoiceNftTransferServiceLive)), ).pipe(Command.withDescription('Build an unsigned ERC721 approve transaction for an invoice claim NFT')); export const invoiceApproveNftExecuteCommand = Command.make( @@ -568,11 +570,12 @@ export const invoiceApproveNftExecuteCommand = Command.make( ({ chain, to, claimId, privateKey, rpcUrl, format }) => Effect.gen(function* () { const params = yield* validateApproveNftParams(chain, to, claimId); - const tx = yield* buildApproveNft(params, getInvoiceControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildApproveNft(params); const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); yield* Console.log(formatResult(result, format as OutputFormat)); - }), + }).pipe(Effect.provide(InvoiceNftTransferServiceLive)), ).pipe(Command.withDescription('Sign and send an ERC721 approve transaction for an invoice claim NFT')); export const invoiceApproveNftCommand = Command.make('approve-nft', {}).pipe( @@ -596,10 +599,11 @@ export const invoiceTransferNftBuildCommand = Command.make( ({ chain, from, to, claimId, format }) => Effect.gen(function* () { const params = yield* validateTransferNftParams(chain, from, to, claimId); - const tx = yield* buildTransferNft(params, getInvoiceControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildTransferNft(params); yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); - }), -).pipe(Command.withDescription('Build an unsigned ERC721 transferFrom transaction for an invoice claim NFT')); + }).pipe(Effect.provide(InvoiceNftTransferServiceLive)), +).pipe(Command.withDescription('Build an unsigned safeTransferFrom transaction for an invoice claim NFT')); export const invoiceTransferNftExecuteCommand = Command.make( 'execute', @@ -615,12 +619,13 @@ export const invoiceTransferNftExecuteCommand = Command.make( ({ chain, from, to, claimId, privateKey, rpcUrl, format }) => Effect.gen(function* () { const params = yield* validateTransferNftParams(chain, from, to, claimId); - const tx = yield* buildTransferNft(params, getInvoiceControllerAddress); + const nftService = yield* NftTransferService; + const tx = yield* nftService.buildTransferNft(params); const signerLayer = makeSignerLayer(privateKey as Hex, Option.getOrUndefined(rpcUrl)); const result = yield* sendTransaction(params.chainId, tx).pipe(Effect.provide(signerLayer)); yield* Console.log(formatResult(result, format as OutputFormat)); - }), -).pipe(Command.withDescription('Sign and send an ERC721 transferFrom transaction for an invoice claim NFT')); + }).pipe(Effect.provide(InvoiceNftTransferServiceLive)), +).pipe(Command.withDescription('Sign and send a safeTransferFrom transaction for an invoice claim NFT')); export const invoiceTransferNftCommand = Command.make('transfer-nft', {}).pipe( Command.withDescription('Transfer an invoice claim NFT to another address (via BullaInvoice controller)'), diff --git a/src/infrastructure/abi/bulla-claim-v2.ts b/src/infrastructure/abi/bulla-claim-v2.ts index 37f23f3..b904e7d 100644 --- a/src/infrastructure/abi/bulla-claim-v2.ts +++ b/src/infrastructure/abi/bulla-claim-v2.ts @@ -1,4 +1,4 @@ -/** BullaClaimV2 (ERC721) ABI — approve and transferFrom functions. */ +/** BullaClaimV2 (ERC721) ABI — approve and safeTransferFrom functions. */ export const bullaClaimV2Abi = [ { inputs: [ @@ -16,7 +16,7 @@ export const bullaClaimV2Abi = [ { internalType: 'address', name: 'to', type: 'address' }, { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, ], - name: 'transferFrom', + name: 'safeTransferFrom', outputs: [], stateMutability: 'nonpayable', type: 'function', diff --git a/src/infrastructure/encoding/viem-approve-encoder.ts b/src/infrastructure/encoding/viem-approve-encoder.ts index 305b163..e7cc62a 100644 --- a/src/infrastructure/encoding/viem-approve-encoder.ts +++ b/src/infrastructure/encoding/viem-approve-encoder.ts @@ -38,7 +38,7 @@ const encodeTransferNft = (params: Omit): Effect.E Effect.sync(() => encodeFunctionData({ abi: bullaClaimV2Abi, - functionName: 'transferFrom', + functionName: 'safeTransferFrom', args: [params.from, params.to, params.claimId], }), ); diff --git a/test/application/services/approve-service.test.ts b/test/application/services/approve-service.test.ts index 96e3cef..91d7387 100644 --- a/test/application/services/approve-service.test.ts +++ b/test/application/services/approve-service.test.ts @@ -2,16 +2,14 @@ import { Effect, Layer } from 'effect'; import { encodeFunctionData } from 'viem'; import { describe, expect, it } from 'vitest'; import { ApproveEncoderService } from '../../../src/application/ports/approve-encoder-port.js'; +import { NftTransferService } from '../../../src/application/ports/nft-transfer-port.js'; import { RegistryService } from '../../../src/application/ports/registry-port.js'; +import { buildApproveCreateClaim, buildApproveErc20 } from '../../../src/application/services/approve-service.js'; import { - buildApproveCreateClaim, - buildApproveErc20, - buildApproveNft, - buildTransferNft, - getClaimContractAddress, - getInvoiceControllerAddress, - getFrendLendControllerAddress, -} from '../../../src/application/services/approve-service.js'; + InvoiceNftTransferServiceLive, + FrendLendNftTransferServiceLive, + ClaimNftTransferServiceLive, +} from '../../../src/application/services/nft-transfer-service.js'; import { CreateClaimApprovalType, type ApproveCreateClaimParams, type ApproveErc20Params, type ApproveNftParams, type TransferNftParams } from '../../../src/domain/types/approve.js'; import type { ChainId, EthAddress, Hex } from '../../../src/domain/types/eth.js'; import { bullaApprovalRegistryAbi } from '../../../src/infrastructure/abi/bulla-approval-registry.js'; @@ -103,7 +101,7 @@ const TestApproveEncoder = Layer.succeed(ApproveEncoderService, { Effect.succeed( encodeFunctionData({ abi: bullaClaimV2Abi, - functionName: 'transferFrom', + functionName: 'safeTransferFrom', args: [params.from as `0x${string}`, params.to as `0x${string}`, params.claimId], }) as Hex, ), @@ -111,6 +109,10 @@ const TestApproveEncoder = Layer.succeed(ApproveEncoderService, { const BuildTestLayers = Layer.mergeAll(TestRegistryService, TestApproveEncoder); +const InvoiceNftTestLayer = Layer.provide(InvoiceNftTransferServiceLive, BuildTestLayers); +const FrendLendNftTestLayer = Layer.provide(FrendLendNftTransferServiceLive, BuildTestLayers); +const ClaimNftTestLayer = Layer.provide(ClaimNftTransferServiceLive, BuildTestLayers); + // --- Tests --- describe('buildApproveCreateClaim', () => { @@ -151,10 +153,11 @@ describe('buildApproveCreateClaim', () => { }); }); -describe('buildApproveNft', () => { +describe('NftTransferService (buildApproveNft)', () => { it('targets invoice controller for invoice claims', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildApproveNft(params)); expect(result.to).toBe(INVOICE_ADDRESS); expect(result.operation).toBe(0); @@ -162,7 +165,8 @@ describe('buildApproveNft', () => { it('targets frendlend controller for loan claims', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params, getFrendLendControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(FrendLendNftTestLayer))); + const result = await Effect.runPromise(nftService.buildApproveNft(params)); expect(result.to).toBe(FRENDLEND_ADDRESS); expect(result.operation).toBe(0); @@ -170,7 +174,8 @@ describe('buildApproveNft', () => { it('targets claim contract for uncontrolled claims', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params, getClaimContractAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(ClaimNftTestLayer))); + const result = await Effect.runPromise(nftService.buildApproveNft(params)); expect(result.to).toBe(CLAIM_V2_ADDRESS); expect(result.operation).toBe(0); @@ -178,14 +183,16 @@ describe('buildApproveNft', () => { it('sets value to "0"', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildApproveNft(params)); expect(result.value).toBe('0'); }); it('encodes calldata starting with the approve function selector', async () => { const params = makeApproveNftParams(); - const result = await Effect.runPromise(buildApproveNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildApproveNft(params)); expect(result.data).toMatch(/^0x[0-9a-f]{8}/); expect(result.data.length).toBeGreaterThan(10); @@ -225,10 +232,11 @@ describe('buildApproveErc20', () => { }); }); -describe('buildTransferNft', () => { +describe('NftTransferService (buildTransferNft)', () => { it('targets invoice controller for invoice claims', async () => { const params = makeTransferNftParams(); - const result = await Effect.runPromise(buildTransferNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildTransferNft(params)); expect(result.to).toBe(INVOICE_ADDRESS); expect(result.operation).toBe(0); @@ -236,15 +244,17 @@ describe('buildTransferNft', () => { it('targets frendlend controller for loan claims', async () => { const params = makeTransferNftParams(); - const result = await Effect.runPromise(buildTransferNft(params, getFrendLendControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(FrendLendNftTestLayer))); + const result = await Effect.runPromise(nftService.buildTransferNft(params)); expect(result.to).toBe(FRENDLEND_ADDRESS); expect(result.operation).toBe(0); }); - it('encodes calldata starting with the transferFrom function selector', async () => { + it('encodes calldata starting with the safeTransferFrom function selector', async () => { const params = makeTransferNftParams(); - const result = await Effect.runPromise(buildTransferNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildTransferNft(params)); expect(result.data).toMatch(/^0x[0-9a-f]{8}/); expect(result.data.length).toBeGreaterThan(10); @@ -252,7 +262,8 @@ describe('buildTransferNft', () => { it('sets value to "0"', async () => { const params = makeTransferNftParams(); - const result = await Effect.runPromise(buildTransferNft(params, getInvoiceControllerAddress).pipe(Effect.provide(BuildTestLayers))); + const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildTransferNft(params)); expect(result.value).toBe('0'); }); From b8368ecc09257e6a21bd3348e02dab1650d2da10 Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 16:36:08 -0400 Subject: [PATCH 7/9] fix: revert safeTransferFrom to transferFrom for NFT transfers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit safeTransferFrom through the controller reverts with ERC721InsufficientApproval when called by an approved (non-owner) party on the deployed Sepolia contracts. transferFrom works correctly for the same flow (approve → transfer by approved party). Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/abi/bulla-claim-v2.ts | 4 ++-- src/infrastructure/encoding/viem-approve-encoder.ts | 2 +- test/application/services/approve-service.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/abi/bulla-claim-v2.ts b/src/infrastructure/abi/bulla-claim-v2.ts index b904e7d..37f23f3 100644 --- a/src/infrastructure/abi/bulla-claim-v2.ts +++ b/src/infrastructure/abi/bulla-claim-v2.ts @@ -1,4 +1,4 @@ -/** BullaClaimV2 (ERC721) ABI — approve and safeTransferFrom functions. */ +/** BullaClaimV2 (ERC721) ABI — approve and transferFrom functions. */ export const bullaClaimV2Abi = [ { inputs: [ @@ -16,7 +16,7 @@ export const bullaClaimV2Abi = [ { internalType: 'address', name: 'to', type: 'address' }, { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, ], - name: 'safeTransferFrom', + name: 'transferFrom', outputs: [], stateMutability: 'nonpayable', type: 'function', diff --git a/src/infrastructure/encoding/viem-approve-encoder.ts b/src/infrastructure/encoding/viem-approve-encoder.ts index e7cc62a..305b163 100644 --- a/src/infrastructure/encoding/viem-approve-encoder.ts +++ b/src/infrastructure/encoding/viem-approve-encoder.ts @@ -38,7 +38,7 @@ const encodeTransferNft = (params: Omit): Effect.E Effect.sync(() => encodeFunctionData({ abi: bullaClaimV2Abi, - functionName: 'safeTransferFrom', + functionName: 'transferFrom', args: [params.from, params.to, params.claimId], }), ); diff --git a/test/application/services/approve-service.test.ts b/test/application/services/approve-service.test.ts index 91d7387..0a4f2d7 100644 --- a/test/application/services/approve-service.test.ts +++ b/test/application/services/approve-service.test.ts @@ -101,7 +101,7 @@ const TestApproveEncoder = Layer.succeed(ApproveEncoderService, { Effect.succeed( encodeFunctionData({ abi: bullaClaimV2Abi, - functionName: 'safeTransferFrom', + functionName: 'transferFrom', args: [params.from as `0x${string}`, params.to as `0x${string}`, params.claimId], }) as Hex, ), @@ -251,7 +251,7 @@ describe('NftTransferService (buildTransferNft)', () => { expect(result.operation).toBe(0); }); - it('encodes calldata starting with the safeTransferFrom function selector', async () => { + it('encodes calldata starting with the transferFrom function selector', async () => { const params = makeTransferNftParams(); const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); const result = await Effect.runPromise(nftService.buildTransferNft(params)); From 12b47606bb13c6f3522ad1cfc440f3e62275e6fe Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 16:43:12 -0400 Subject: [PATCH 8/9] fix: use 4-arg safeTransferFrom to bypass 3-arg external wrapper The 3-arg safeTransferFrom(address,address,uint256) is an external wrapper that internally calls the 4-arg public version. By calling the 4-arg safeTransferFrom(address,address,uint256,bytes) directly with empty bytes, we avoid any msg.sender issues in the external-to-public delegation path. Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/abi/bulla-claim-v2.ts | 5 +++-- src/infrastructure/encoding/viem-approve-encoder.ts | 4 ++-- test/application/services/approve-service.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/infrastructure/abi/bulla-claim-v2.ts b/src/infrastructure/abi/bulla-claim-v2.ts index 37f23f3..10bd31f 100644 --- a/src/infrastructure/abi/bulla-claim-v2.ts +++ b/src/infrastructure/abi/bulla-claim-v2.ts @@ -1,4 +1,4 @@ -/** BullaClaimV2 (ERC721) ABI — approve and transferFrom functions. */ +/** BullaClaimV2 (ERC721) ABI — approve, safeTransferFrom and ownerOf functions. */ export const bullaClaimV2Abi = [ { inputs: [ @@ -15,8 +15,9 @@ export const bullaClaimV2Abi = [ { internalType: 'address', name: 'from', type: 'address' }, { internalType: 'address', name: 'to', type: 'address' }, { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, ], - name: 'transferFrom', + name: 'safeTransferFrom', outputs: [], stateMutability: 'nonpayable', type: 'function', diff --git a/src/infrastructure/encoding/viem-approve-encoder.ts b/src/infrastructure/encoding/viem-approve-encoder.ts index 305b163..aafcc29 100644 --- a/src/infrastructure/encoding/viem-approve-encoder.ts +++ b/src/infrastructure/encoding/viem-approve-encoder.ts @@ -38,8 +38,8 @@ const encodeTransferNft = (params: Omit): Effect.E Effect.sync(() => encodeFunctionData({ abi: bullaClaimV2Abi, - functionName: 'transferFrom', - args: [params.from, params.to, params.claimId], + functionName: 'safeTransferFrom', + args: [params.from, params.to, params.claimId, '0x'], }), ); diff --git a/test/application/services/approve-service.test.ts b/test/application/services/approve-service.test.ts index 0a4f2d7..f205125 100644 --- a/test/application/services/approve-service.test.ts +++ b/test/application/services/approve-service.test.ts @@ -101,8 +101,8 @@ const TestApproveEncoder = Layer.succeed(ApproveEncoderService, { Effect.succeed( encodeFunctionData({ abi: bullaClaimV2Abi, - functionName: 'transferFrom', - args: [params.from as `0x${string}`, params.to as `0x${string}`, params.claimId], + functionName: 'safeTransferFrom', + args: [params.from as `0x${string}`, params.to as `0x${string}`, params.claimId, '0x'], }) as Hex, ), }); @@ -251,7 +251,7 @@ describe('NftTransferService (buildTransferNft)', () => { expect(result.operation).toBe(0); }); - it('encodes calldata starting with the transferFrom function selector', async () => { + it('encodes calldata starting with the safeTransferFrom function selector', async () => { const params = makeTransferNftParams(); const nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); const result = await Effect.runPromise(nftService.buildTransferNft(params)); From 993617a6c61d29e6bd3036cee966751c5366d69c Mon Sep 17 00:00:00 2001 From: "Gerald (AI Assistant)" Date: Mon, 16 Mar 2026 17:03:30 -0400 Subject: [PATCH 9/9] fix: clear EOF contract code from anvil accounts on Sepolia fork Hardhat's well-known test accounts (0xf39Fd6e..., 0x70997970...) have EOF contracts deployed on Sepolia, causing safeTransferFrom to call onERC721Received and revert. Clear account code via anvil_setCode after fork starts so these addresses behave as EOAs. Co-Authored-By: Claude Opus 4.6 --- test/e2e/setup/anvil.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/e2e/setup/anvil.ts b/test/e2e/setup/anvil.ts index c6121a7..7e79ee0 100644 --- a/test/e2e/setup/anvil.ts +++ b/test/e2e/setup/anvil.ts @@ -1,4 +1,5 @@ import { type ChildProcess, spawn } from 'node:child_process'; +import { ANVIL_ACCOUNTS } from './constants.js'; export interface AnvilInstance { process: ChildProcess; @@ -27,6 +28,12 @@ export async function startAnvil(forkUrl: string): Promise { await waitForAnvil(rpcUrl, 30_000); + // Clear any contract code at anvil test accounts. + // On Sepolia, these well-known Hardhat addresses have EOF contracts deployed, + // which causes safeTransferFrom to call onERC721Received and revert. + await clearAccountCode(rpcUrl, ANVIL_ACCOUNTS.account0.address); + await clearAccountCode(rpcUrl, ANVIL_ACCOUNTS.account1.address); + return { process: proc, rpcUrl, @@ -40,6 +47,14 @@ export async function startAnvil(forkUrl: string): Promise { }; } +async function clearAccountCode(rpcUrl: string, address: string): Promise { + await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'anvil_setCode', params: [address, '0x'], id: 1 }), + }); +} + async function waitForAnvil(rpcUrl: string, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) {