Skip to content
8 changes: 7 additions & 1 deletion scripts/sync-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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}',`);

Expand Down Expand Up @@ -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<ChainId, ChainContracts> = {
Expand Down
12 changes: 12 additions & 0 deletions src/application/ports/approve-encoder-port.ts
Original file line number Diff line number Diff line change
@@ -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<ApproveCreateClaimParams, 'chainId'>): Effect.Effect<Hex, never, never>;
encodeApproveNft(params: Omit<ApproveNftParams, 'chainId'>): Effect.Effect<Hex, never, never>;
encodeApproveErc20(params: Omit<ApproveErc20Params, 'chainId' | 'token'>): Effect.Effect<Hex, never, never>;
encodeTransferNft(params: Omit<TransferNftParams, 'chainId'>): Effect.Effect<Hex, never, never>;
}

export const ApproveEncoderService = Context.GenericTag<ApproveEncoderService>('@services/ApproveEncoderService');
15 changes: 15 additions & 0 deletions src/application/ports/nft-transfer-port.ts
Original file line number Diff line number Diff line change
@@ -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<UnsignedTransaction, ContractNotFoundError | UnsupportedChainError>;
readonly buildTransferNft: (
params: TransferNftParams,
) => Effect.Effect<UnsignedTransaction, ContractNotFoundError | UnsupportedChainError>;
}

export const NftTransferService = Context.GenericTag<NftTransferService>('@services/NftTransferService');
2 changes: 2 additions & 0 deletions src/application/ports/registry-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface RegistryService {
readonly getInstantPaymentAddress: (chainId: ChainId) => Effect.Effect<EthAddress, ContractNotFoundError | UnsupportedChainError>;
readonly getInvoiceAddress: (chainId: ChainId) => Effect.Effect<EthAddress, ContractNotFoundError | UnsupportedChainError>;
readonly getFrendLendAddress: (chainId: ChainId) => Effect.Effect<EthAddress, ContractNotFoundError | UnsupportedChainError>;
readonly getApprovalRegistryAddress: (chainId: ChainId) => Effect.Effect<EthAddress, ContractNotFoundError | UnsupportedChainError>;
readonly getClaimAddress: (chainId: ChainId) => Effect.Effect<EthAddress, ContractNotFoundError | UnsupportedChainError>;
readonly validateFactoringPool: (
chainId: ChainId,
address: EthAddress,
Expand Down
40 changes: 40 additions & 0 deletions src/application/services/approve-service.ts
Original file line number Diff line number Diff line change
@@ -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<UnsignedTransaction, ContractNotFoundError | UnsupportedChainError, RegistryService | ApproveEncoderService> =>
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<UnsignedTransaction, never, ApproveEncoderService> =>
Effect.gen(function* () {
const encoder = yield* ApproveEncoderService;

const data = yield* encoder.encodeApproveErc20(params);

return {
to: params.token,
value: '0',
data,
operation: 0 as const,
};
});
63 changes: 63 additions & 0 deletions src/application/services/nft-transfer-service.ts
Original file line number Diff line number Diff line change
@@ -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<EthAddress, ContractNotFoundError | UnsupportedChainError>,
) =>
Layer.effect(
NftTransferService,
Effect.gen(function* () {
const registry = yield* RegistryService;
const encoder = yield* ApproveEncoderService;

return {
buildApproveNft: (
params: ApproveNftParams,
): Effect.Effect<UnsignedTransaction, ContractNotFoundError | UnsupportedChainError> =>
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<UnsignedTransaction, ContractNotFoundError | UnsupportedChainError> =>
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),
);
127 changes: 127 additions & 0 deletions src/cli/approve/commands.ts
Original file line number Diff line number Diff line change
@@ -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;
44 changes: 44 additions & 0 deletions src/cli/approve/options.ts
Original file line number Diff line number Diff line change
@@ -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)"),
);
Loading
Loading