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..82f3b09 --- /dev/null +++ b/src/application/ports/approve-encoder-port.ts @@ -0,0 +1,12 @@ +import { Context, Effect } from 'effect'; +import type { Hex } from '../../domain/types/eth.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/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/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..a9f2d81 --- /dev/null +++ b/src/application/services/approve-service.ts @@ -0,0 +1,40 @@ +import { Effect } from 'effect'; +import type { ContractNotFoundError, UnsupportedChainError } from '../../domain/errors.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'; + +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 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/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/approve/commands.ts b/src/cli/approve/commands.ts new file mode 100644 index 0000000..7bdc5b2 --- /dev/null +++ b/src/cli/approve/commands.ts @@ -0,0 +1,127 @@ +import { Command } from '@effect/cli'; +import { Console, Effect, Option } from 'effect'; +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'; +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, + bindingAllowedOption, + controllerOption, + erc20TokenOption, + spenderOption, +} from './options.js'; +import { + validateApproveCreateClaimParams, + validateApproveErc20Params, +} 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 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, + approveErc20Command, +] as const; diff --git a/src/cli/approve/options.ts b/src/cli/approve/options.ts new file mode 100644 index 0000000..2d768dd --- /dev/null +++ b/src/cli/approve/options.ts @@ -0,0 +1,44 @@ +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.withDefault('approved'), + Options.withDescription('Approval type: unapproved, creditor-only, debtor-only, or approved (default: 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 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'), +); + +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..7cbd646 --- /dev/null +++ b/src/cli/approve/validation.ts @@ -0,0 +1,91 @@ +import { Either, Option } from 'effect'; +import type { InvalidAddressError, InvalidAmountError, InvalidChainError } from '../../domain/errors.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 { + 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, + 'creditor-only': CreateClaimApprovalType.CreditorOnly, + 'debtor-only': CreateClaimApprovalType.DebtorOnly, + '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, + approvalType: string, + approvalCount: string, + bindingAllowed: boolean, +): Either.Either => + Either.gen(function* () { + return { + chainId: yield* validateChainId(chain), + controller: yield* validateAddress(controller), + approvalType: yield* validateApprovalType(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 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, + 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..71d394c --- /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, ERC20)'), + Command.withSubcommands([...approveCommands]), +); diff --git a/src/cli/frendlend/commands.ts b/src/cli/frendlend/commands.ts index 8d86756..3641dda 100644 --- a/src/cli/frendlend/commands.ts +++ b/src/cli/frendlend/commands.ts @@ -1,5 +1,7 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; +import { NftTransferService } from '../../application/ports/nft-transfer-port.js'; +import { FrendLendNftTransferServiceLive } from '../../application/services/nft-transfer-service.js'; import { buildAcceptLoan, buildImpairLoan, @@ -35,6 +37,8 @@ import { paymentAmountOption, periodsPerYearOption, } from '../options/invoice-options.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, @@ -437,6 +441,102 @@ 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 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( + '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 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( + 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 nftService = yield* NftTransferService; + const tx = yield* nftService.buildTransferNft(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }).pipe(Effect.provide(FrendLendNftTransferServiceLive)), +).pipe(Command.withDescription('Build an unsigned safeTransferFrom 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 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(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)'), + Command.withSubcommands([frendlendTransferNftBuildCommand, frendlendTransferNftExecuteCommand]), +); + // ============================================================================ // EXPORT ALL FRENDLEND COMMANDS // ============================================================================ @@ -449,4 +549,6 @@ export const frendlendCommands = [ frendlendImpairLoanCommand, frendlendMarkPaidCommand, frendlendSetCallbackCommand, + frendlendApproveNftCommand, + frendlendTransferNftCommand, ] as const; diff --git a/src/cli/invoice/commands.ts b/src/cli/invoice/commands.ts index fa7ba6a..0f3e0ed 100644 --- a/src/cli/invoice/commands.ts +++ b/src/cli/invoice/commands.ts @@ -1,5 +1,7 @@ import { Command } from '@effect/cli'; import { Console, Effect, Option } from 'effect'; +import { NftTransferService } from '../../application/ports/nft-transfer-port.js'; +import { InvoiceNftTransferServiceLive } from '../../application/services/nft-transfer-service.js'; import { buildAcceptPurchaseOrder, buildCancelInvoice, @@ -34,6 +36,8 @@ import { paymentAmountOption, periodsPerYearOption, } from '../options/invoice-options.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, @@ -532,6 +536,102 @@ 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 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( + '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 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( + 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 nftService = yield* NftTransferService; + const tx = yield* nftService.buildTransferNft(params); + yield* Console.log(formatTransaction(tx, params.chainId, format as OutputFormat)); + }).pipe(Effect.provide(InvoiceNftTransferServiceLive)), +).pipe(Command.withDescription('Build an unsigned safeTransferFrom 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 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(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)'), + Command.withSubcommands([invoiceTransferNftBuildCommand, invoiceTransferNftExecuteCommand]), +); + // ============================================================================ // EXPORT ALL INVOICE COMMANDS // ============================================================================ @@ -546,4 +646,6 @@ export const invoiceCommands = [ invoiceSetCallbackCommand, invoiceAcceptPoCommand, invoiceDeliverPoCommand, + invoiceApproveNftCommand, + invoiceTransferNftCommand, ] as const; diff --git a/src/domain/types/approve.ts b/src/domain/types/approve.ts new file mode 100644 index 0000000..3e8174a --- /dev/null +++ b/src/domain/types/approve.ts @@ -0,0 +1,36 @@ +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 TransferNftParams { + chainId: ChainId; + from: EthAddress; + 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..10bd31f --- /dev/null +++ b/src/infrastructure/abi/bulla-claim-v2.ts @@ -0,0 +1,32 @@ +/** BullaClaimV2 (ERC721) ABI — approve, safeTransferFrom and ownerOf functions. */ +export const bullaClaimV2Abi = [ + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'approve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { 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: 'safeTransferFrom', + 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/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..aafcc29 --- /dev/null +++ b/src/infrastructure/encoding/viem-approve-encoder.ts @@ -0,0 +1,51 @@ +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, 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'; + +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], + }), + ); + +const encodeTransferNft = (params: Omit): Effect.Effect => + Effect.sync(() => + encodeFunctionData({ + abi: bullaClaimV2Abi, + functionName: 'safeTransferFrom', + args: [params.from, params.to, params.claimId, '0x'], + }), + ); + +export const ViemApproveEncoderLive = Layer.succeed(ApproveEncoderService, { + encodeApproveCreateClaim, + encodeApproveNft, + encodeApproveErc20, + encodeTransferNft, +}); 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( diff --git a/test/application/services/approve-service.test.ts b/test/application/services/approve-service.test.ts new file mode 100644 index 0000000..f205125 --- /dev/null +++ b/test/application/services/approve-service.test.ts @@ -0,0 +1,270 @@ +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 { + 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'; +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 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; + +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 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, + spender: SPENDER, + amount: 1000000n, + ...overrides, +}); + +// --- Test layers --- + +const TestRegistryService = Layer.succeed(RegistryService, { + getInstantPaymentAddress: () => 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), +}); + +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, + ), + encodeTransferNft: params => + Effect.succeed( + encodeFunctionData({ + abi: bullaClaimV2Abi, + functionName: 'safeTransferFrom', + args: [params.from as `0x${string}`, params.to as `0x${string}`, params.claimId, '0x'], + }) as Hex, + ), +}); + +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', () => { + 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('NftTransferService (buildApproveNft)', () => { + it('targets invoice controller for invoice claims', async () => { + const params = makeApproveNftParams(); + 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); + }); + + it('targets frendlend controller for loan claims', async () => { + const params = makeApproveNftParams(); + 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); + }); + + it('targets claim contract for uncontrolled claims', async () => { + const params = makeApproveNftParams(); + 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); + }); + + it('sets value to "0"', async () => { + const params = makeApproveNftParams(); + 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 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); + }); +}); + +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); + }); +}); + +describe('NftTransferService (buildTransferNft)', () => { + it('targets invoice controller for invoice claims', async () => { + const params = makeTransferNftParams(); + 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); + }); + + it('targets frendlend controller for loan claims', async () => { + const params = makeTransferNftParams(); + 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 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)); + + 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 nftService = await Effect.runPromise(NftTransferService.pipe(Effect.provide(InvoiceNftTestLayer))); + const result = await Effect.runPromise(nftService.buildTransferNft(params)); + + expect(result.value).toBe('0'); + }); +}); 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..fa37abb --- /dev/null +++ b/test/e2e/approve.e2e.test.ts @@ -0,0 +1,334 @@ +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, SEPOLIA_RPC_URL } 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', () => { + 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 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 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('invoice: approve-nft -> transfer-nft lifecycle (e2e)', () => { + let anvil: AnvilInstance; + + beforeAll(async () => { + 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); + await approveCreateClaim(anvil.rpcUrl, ANVIL_ACCOUNTS.account1.privateKey as `0x${string}`, CONTRACTS.bullaInvoice); + }); + + afterAll(() => { + anvil?.stop(); + }); + + 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()); + }); + }); +}); + +describe('frendlend: approve-nft -> transfer-nft lifecycle (e2e)', () => { + let anvil: AnvilInstance; + + beforeAll(async () => { + 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); + 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, + ); + }); + + afterAll(() => { + anvil?.stop(); + }); + + 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()); + }); + }); +}); 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) { 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)