diff --git a/integration/tests/challenge_channel.test.ts b/integration/tests/challenge_channel.test.ts index eeec4c5bd..8ae017b55 100644 --- a/integration/tests/challenge_channel.test.ts +++ b/integration/tests/challenge_channel.test.ts @@ -5,10 +5,7 @@ import { Identity } from '@/identity'; import { TestNitroliteClient } from '@/nitroliteClient'; import { CONFIG } from '@/setup'; import { getChannelUpdatePredicateWithStatus, TestWebSocket } from '@/ws'; -import { - parseChannelUpdateResponse, - RPCChannelStatus, -} from '@erc7824/nitrolite'; +import { parseChannelUpdateResponse, RPCChannelStatus } from '@erc7824/nitrolite'; import { parseUnits } from 'viem'; describe('Challenge channel', () => { @@ -53,6 +50,12 @@ describe('Challenge channel', () => { const channelId = createResponse.channelId; expect(createResponse.version).toBe(1); // 1 because channel was resized as well + const challengedChannelUpdatePromise = ws.waitForMessage( + getChannelUpdatePredicateWithStatus(RPCChannelStatus.Challenged), + undefined, + 5000 + ); + const challengeReceipt = await client.challengeChannel({ channelId: channelId, candidateState: { @@ -66,11 +69,6 @@ describe('Challenge channel', () => { expect(challengeReceipt).toBeDefined(); - const challengedChannelUpdatePromise = ws.waitForMessage( - getChannelUpdatePredicateWithStatus(RPCChannelStatus.Challenged), - 5000 - ); - const challengeConfirmation = await blockUtils.waitForTransaction(challengeReceipt); expect(challengeConfirmation).toBeDefined(); diff --git a/sdk/src/abis/aa/execute.ts b/sdk/src/abis/aa/execute.ts new file mode 100644 index 000000000..777aba688 --- /dev/null +++ b/sdk/src/abis/aa/execute.ts @@ -0,0 +1,78 @@ +export const AAExecuteAbi = [ + { + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + { + internalType: 'enum Operation', + name: '', + type: 'uint8', + }, + ], + name: 'execute', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + internalType: 'struct Call[]', + name: 'calls', + type: 'tuple[]', + }, + ], + name: 'executeBatch', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'executeDelegateCall', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +]; diff --git a/sdk/src/abis/aa/user_op_event.ts b/sdk/src/abis/aa/user_op_event.ts new file mode 100644 index 000000000..27342442a --- /dev/null +++ b/sdk/src/abis/aa/user_op_event.ts @@ -0,0 +1,51 @@ +import { AbiEvent } from 'viem'; + +export const UserOpEventAbi: AbiEvent = { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'bytes32', + name: 'userOpHash', + type: 'bytes32', + }, + { + indexed: true, + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'paymaster', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'nonce', + type: 'uint256', + }, + { + indexed: false, + internalType: 'bool', + name: 'success', + type: 'bool', + }, + { + indexed: false, + internalType: 'uint256', + name: 'actualGasCost', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'actualGasUsed', + type: 'uint256', + }, + ], + name: 'UserOperationEvent', + type: 'event', +}; diff --git a/sdk/src/client/contract_writer/aa_v06.ts b/sdk/src/client/contract_writer/aa_v06.ts new file mode 100644 index 000000000..ee7118c93 --- /dev/null +++ b/sdk/src/client/contract_writer/aa_v06.ts @@ -0,0 +1,259 @@ +import { + Account, + Call, + encodeFunctionData, + ExactPartial, + Hex, + numberToHex, + pad, + publicActions, + PublicClient, + RpcUserOperation, + SignedAuthorization, + toEventSelector, +} from 'viem'; +import { CallsDetails, ContractCallParams, ContractWriter, WriteResult } from './types'; +import { entryPoint06Address, SmartAccount, UserOperation } from 'viem/account-abstraction'; +import { AAExecuteAbi } from '../../abis/aa/execute'; +import { BundlerClientV06, PartialUserOperationV06 } from './aa_v06_types'; +import { UserOpEventAbi } from '../../abis/aa/user_op_event'; +import Errors from '../../errors'; + +export type AAV06ContractWriterConfig = { + publicClient: PublicClient; + smartAccount: SmartAccount; + bundlerClient: BundlerClientV06; + + pollingInterval?: number; + pollingTimeout?: number; +}; + +export class AAV06ContractWriter implements ContractWriter { + public readonly publicClient: PublicClient; + public readonly smartAccount: SmartAccount; + public readonly bundlerClient: BundlerClientV06; + + private readonly pollingInterval: number; + private readonly pollingTimeout: number; + + constructor(config: AAV06ContractWriterConfig) { + if (!config.publicClient) throw new Errors.MissingParameterError('publicClient'); + if (!config.smartAccount) throw new Errors.MissingParameterError('smartAccount'); + if (!config.bundlerClient) throw new Errors.MissingParameterError('bundlerClient'); + + this.publicClient = config.publicClient; + this.smartAccount = config.smartAccount; + this.bundlerClient = config.bundlerClient; + + this.pollingInterval = config.pollingInterval ?? 5000; + this.pollingTimeout = config.pollingTimeout ?? 120000; + } + + async write(callsDetails: CallsDetails): Promise { + const calls = callsDetails.calls.map((call) => this._prepareCalldata(call)); + + const txHash = await this._writeCalls(calls); + return { txHashes: [txHash] }; + } + + getAccount(): Account { + return this.smartAccount; + } + + private _prepareCalldata(callParams: ContractCallParams): Call { + const encoded = encodeFunctionData({ + abi: callParams.abi, + functionName: callParams.functionName, + args: callParams.args, + }); + + return { + to: callParams.address, + value: callParams.value ?? 0n, + data: encoded, + }; + } + + private async _writeCalls(calls: Call[]): Promise { + const chainId = await this.publicClient.getChainId(); + + const partialUserOperation = await this._callsToPartialUserOperation(calls); + const gasParameters = await this.bundlerClient.estimateUserOperation(chainId, partialUserOperation); + + const userOperation = this._formatUserOperation( + // @ts-ignore + { + ...partialUserOperation, + nonce: ('0x' + partialUserOperation.nonce.toString(16)) as Hex, + ...gasParameters, + }, + ) as Required>; + + userOperation.signature = await this.smartAccount.signUserOperation({ + chainId, + ...userOperation, + }); + + const userOperationSerialized = this._formatUserOperationRequest(userOperation); + const userOpHash = await this.bundlerClient.sendUserOperation(chainId, userOperationSerialized); + + return await this._waitForUserOperationReceipt(userOpHash); + } + + private async _callsToPartialUserOperation(calls: Call[]): Promise { + const senderAddress = this.smartAccount.address; + const nonce = await this.smartAccount.getNonce(); + + let initCode: Hex = '0x'; + + if (!(await this.smartAccount.isDeployed())) { + // NOTE: for EntryPoint v0.6, the initCode is the factoryData + const { factory, factoryData } = await this.smartAccount.getFactoryArgs(); + + if (factory && factoryData) { + initCode = (factory + factoryData.substring(2)) as Hex; + } else { + throw new Error('SmartAccount factory is not configured properly'); + } + } + + const partialUserOperation: PartialUserOperationV06 = { + sender: senderAddress, + nonce, + initCode, + // NOTE: not using SmartWallet interface `encodeCalls`, as we are not fully satisfied with Kernel's implementation + // Please, change if we change SW provider + callData: this._encodeExecuteBatchCall(calls), + paymasterAndData: '0x', + signature: '0x', + }; + + partialUserOperation.signature = await this.smartAccount.getStubSignature(partialUserOperation); + + return partialUserOperation; + } + + private async _waitForUserOperationReceipt(userOpHash: Hex): Promise { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const intervalId = setInterval(async () => { + try { + const chainId = this.publicClient.chain?.id; + if (!chainId) { + clearInterval(intervalId); + reject(new Error('PublicClient chain is not configured')); + return; + } + + const logs = await this.bundlerClient.fetchLogs( + this.publicClient.chain!.id, + [entryPoint06Address], + [[toEventSelector(UserOpEventAbi)], [userOpHash]], + ); + + if (logs.length > 0) { + const txHash = logs[logs.length - 1].transactionHash; + + if (txHash) { + clearInterval(intervalId); + resolve(txHash); + return; + } + } + + if (Date.now() - startTime >= this.pollingTimeout) { + clearInterval(intervalId); + const waitTimeoutError = new Error( + `Timeout for waiting UserOperationEvent. Waited for: ` + this.pollingTimeout + ' ms', + ); + + reject(waitTimeoutError); + return; + } + } catch (error) { + clearInterval(intervalId); + reject(error); + } + }, this.pollingInterval); + }); + } + + private _encodeExecuteBatchCall = (args: readonly Call[]) => { + return encodeFunctionData({ + abi: AAExecuteAbi, + functionName: 'executeBatch', + args: [ + args.map((arg) => { + return { + to: arg.to, + value: arg.value || 0n, + data: arg.data || '0x', + }; + }), + ], + }); + }; + + private _formatUserOperation = (parameters: RpcUserOperation): UserOperation => { + const userOperation = { ...parameters } as unknown as UserOperation; + + if (parameters.callGasLimit) userOperation.callGasLimit = BigInt(parameters.callGasLimit); + if (parameters.maxFeePerGas) userOperation.maxFeePerGas = BigInt(parameters.maxFeePerGas); + if (parameters.maxPriorityFeePerGas) + userOperation.maxPriorityFeePerGas = BigInt(parameters.maxPriorityFeePerGas); + if (parameters.nonce) userOperation.nonce = BigInt(parameters.nonce); + if (parameters.paymasterPostOpGasLimit) + userOperation.paymasterPostOpGasLimit = BigInt(parameters.paymasterPostOpGasLimit); + if (parameters.paymasterVerificationGasLimit) + userOperation.paymasterVerificationGasLimit = BigInt(parameters.paymasterVerificationGasLimit); + if (parameters.preVerificationGas) userOperation.preVerificationGas = BigInt(parameters.preVerificationGas); + if (parameters.verificationGasLimit) + userOperation.verificationGasLimit = BigInt(parameters.verificationGasLimit); + + return userOperation; + }; + + private _formatUserOperationRequest = (request: ExactPartial) => { + const rpcRequest = {} as RpcUserOperation; + + if (typeof request.callData !== 'undefined') rpcRequest.callData = request.callData; + if (typeof request.callGasLimit !== 'undefined') rpcRequest.callGasLimit = numberToHex(request.callGasLimit); + if (typeof request.factory !== 'undefined') rpcRequest.factory = request.factory; + if (typeof request.factoryData !== 'undefined') rpcRequest.factoryData = request.factoryData; + if (typeof request.initCode !== 'undefined') rpcRequest.initCode = request.initCode; + if (typeof request.maxFeePerGas !== 'undefined') rpcRequest.maxFeePerGas = numberToHex(request.maxFeePerGas); + if (typeof request.maxPriorityFeePerGas !== 'undefined') + rpcRequest.maxPriorityFeePerGas = numberToHex(request.maxPriorityFeePerGas); + if (typeof request.nonce !== 'undefined') rpcRequest.nonce = numberToHex(request.nonce); + if (typeof request.paymaster !== 'undefined') rpcRequest.paymaster = request.paymaster; + if (typeof request.paymasterAndData !== 'undefined') + rpcRequest.paymasterAndData = request.paymasterAndData || '0x'; + if (typeof request.paymasterData !== 'undefined') rpcRequest.paymasterData = request.paymasterData; + if (typeof request.paymasterPostOpGasLimit !== 'undefined') + rpcRequest.paymasterPostOpGasLimit = numberToHex(request.paymasterPostOpGasLimit); + if (typeof request.paymasterVerificationGasLimit !== 'undefined') + rpcRequest.paymasterVerificationGasLimit = numberToHex(request.paymasterVerificationGasLimit); + if (typeof request.preVerificationGas !== 'undefined') + rpcRequest.preVerificationGas = numberToHex(request.preVerificationGas); + if (typeof request.sender !== 'undefined') rpcRequest.sender = request.sender; + if (typeof request.signature !== 'undefined') rpcRequest.signature = request.signature; + if (typeof request.verificationGasLimit !== 'undefined') + rpcRequest.verificationGasLimit = numberToHex(request.verificationGasLimit); + if (typeof request.authorization !== 'undefined') + rpcRequest.eip7702Auth = this._formatAuthorization(request.authorization); + + return rpcRequest; + }; + + private _formatAuthorization = (authorization: SignedAuthorization) => { + return { + address: authorization.address, + chainId: numberToHex(authorization.chainId), + nonce: numberToHex(authorization.nonce), + r: authorization.r ? numberToHex(BigInt(authorization.r), { size: 32 }) : pad('0x', { size: 32 }), + s: authorization.s ? numberToHex(BigInt(authorization.s), { size: 32 }) : pad('0x', { size: 32 }), + yParity: authorization.yParity ? numberToHex(authorization.yParity, { size: 1 }) : pad('0x', { size: 32 }), + }; + }; +} diff --git a/sdk/src/client/contract_writer/aa_v06_types.ts b/sdk/src/client/contract_writer/aa_v06_types.ts new file mode 100644 index 000000000..55237bcb4 --- /dev/null +++ b/sdk/src/client/contract_writer/aa_v06_types.ts @@ -0,0 +1,30 @@ +import { Hex, Prettify, PartialBy, Address, Log, RpcUserOperation } from 'viem'; +import { UserOperation } from 'viem/account-abstraction'; + +export type PartialUserOperationV06 = Prettify< + PartialBy< + Required>, + | 'callGasLimit' + | 'maxFeePerGas' + | 'maxPriorityFeePerGas' + | 'paymasterAndData' + | 'preVerificationGas' + | 'verificationGasLimit' + | 'authorization' + > +>; + +export type GasParametersV06 = { + callGasLimit: Hex; + verificationGasLimit: Hex; + preVerificationGas: Hex; + paymasterAndData: Hex; + maxFeePerGas: Hex; + maxPriorityFeePerGas: Hex; +}; + +export interface BundlerClientV06 { + estimateUserOperation(chainId: number, userOp: PartialUserOperationV06): Promise; + sendUserOperation(chainId: number, userOp: RpcUserOperation<'0.6'>): Promise; + fetchLogs(chainId: number, addresses: Address[], topics: Hex[][]): Promise; +} diff --git a/sdk/src/client/contract_writer/eoa.ts b/sdk/src/client/contract_writer/eoa.ts new file mode 100644 index 000000000..868803d9d --- /dev/null +++ b/sdk/src/client/contract_writer/eoa.ts @@ -0,0 +1,81 @@ +import { + Account, + Chain, + Client, + Hash, + Hex, + ParseAccount, + publicActions, + PublicClient, + TransactionReceipt, + Transport, + WalletActions, + WalletClient, +} from 'viem'; +import { CallsDetails, ContractCallParams, ContractWriter, WriteResult } from './types'; +import Errors from '../../errors'; + +export type EOAContractWriterConfig = { + publicClient: PublicClient; + walletClient: WalletClient>; +}; + +export class EOAContractWriter implements ContractWriter { + public readonly publicClient: PublicClient; + public readonly walletClient: WalletClient>; + public readonly account: ParseAccount; + + constructor(config: EOAContractWriterConfig) { + if (!config.publicClient) throw new Errors.MissingParameterError('publicClient'); + if (!config.walletClient) throw new Errors.MissingParameterError('walletClient'); + if (!config.walletClient.account) throw new Errors.MissingParameterError('walletClient.account'); + + this.publicClient = config.publicClient; + this.walletClient = config.walletClient; + this.account = this.walletClient.account; + } + + async write(callsDetails: CallsDetails): Promise { + if (callsDetails.calls.length < 1) { + throw new Error('No calls provided'); + } + + const result: WriteResult = { txHashes: [] }; + + // EOA writer does not support batching, so we execute calls sequentially + for (const call of callsDetails.calls) { + const txHash = await this._writeCall(call); + await this.waitForTransaction(txHash); + + result.txHashes.push(txHash); + } + + return result; + } + + getAccount(): Account { + return this.account; + } + + private async _writeCall(callParams: ContractCallParams): Promise { + const { request } = await this.publicClient.simulateContract({ + ...callParams, + account: this.account, + }); + + return this.walletClient.writeContract({ + ...request, + account: this.account, + } as any); + } + + async waitForTransaction(hash: Hash): Promise { + const receipt = await this.publicClient.waitForTransactionReceipt({ hash }); + + if (receipt.status === 'reverted') { + throw new Error(`Transaction reverted`); + } + + return receipt; + } +} diff --git a/sdk/src/client/contract_writer/types.ts b/sdk/src/client/contract_writer/types.ts new file mode 100644 index 000000000..940c251e6 --- /dev/null +++ b/sdk/src/client/contract_writer/types.ts @@ -0,0 +1,43 @@ +import { Abi, Account, Address, Chain, ContractFunctionParameters, Hex } from 'viem'; + +export interface ContractWriter { + write: (callDetails: CallsDetails) => Promise; + getAccount: () => Account; +} + +export interface ContractCallParams< + TAbi extends Abi = Abi, + TFunctionName extends string = string, + TChain extends Chain | undefined = Chain | undefined, +> { + // Required parameters + address: Address; + abi: TAbi; + functionName: TFunctionName; + + // Optional parameters + args?: readonly unknown[]; + account?: Account | Address; + chain?: TChain; + + // Transaction parameters + value?: bigint; + gas?: bigint; + // gasPrice?: bigint; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + nonce?: number; + + // Additional optional parameters + dataSuffix?: `0x${string}`; + // type?: 'legacy' | 'eip2930' | 'eip1559'; + type?: "eip7702"; +} + +export interface CallsDetails { + calls: ContractCallParams[]; +} + +export interface WriteResult { + txHashes: Hex[]; +} diff --git a/sdk/src/client/index.ts b/sdk/src/client/index.ts index ea682344c..fc0501e83 100644 --- a/sdk/src/client/index.ts +++ b/sdk/src/client/index.ts @@ -22,6 +22,8 @@ import { State, } from './types'; import { StateSigner } from './signer'; +import { CallsDetails, ContractWriter, WriteResult } from './contract_writer/types'; +import { EOAContractWriter } from './contract_writer/eoa'; const CUSTODY_MIN_CHALLENGE_DURATION = 3600n; @@ -31,12 +33,12 @@ const CUSTODY_MIN_CHALLENGE_DURATION = 3600n; */ export class NitroliteClient { public readonly publicClient: PublicClient; - public readonly walletClient: WalletClient>; public readonly account: ParseAccount; public readonly addresses: ContractAddresses; public readonly challengeDuration: bigint; public readonly txPreparer: NitroliteTransactionPreparer; public readonly chainId: number; + public readonly contractWriter: ContractWriter; private readonly stateSigner: StateSigner; private readonly nitroliteService: NitroliteService; private readonly erc20Service: Erc20Service; @@ -44,8 +46,6 @@ export class NitroliteClient { constructor(config: NitroliteClientConfig) { if (!config.publicClient) throw new Errors.MissingParameterError('publicClient'); - if (!config.walletClient) throw new Errors.MissingParameterError('walletClient'); - if (!config.walletClient.account) throw new Errors.MissingParameterError('walletClient.account'); if (!config.challengeDuration) throw new Errors.MissingParameterError('challengeDuration'); if (config.challengeDuration < CUSTODY_MIN_CHALLENGE_DURATION) throw new Errors.InvalidParameterError( @@ -56,27 +56,39 @@ export class NitroliteClient { if (!config.chainId) throw new Errors.MissingParameterError('chainId'); this.publicClient = config.publicClient; - this.walletClient = config.walletClient; this.stateSigner = config.stateSigner; - this.account = config.walletClient.account; this.addresses = config.addresses; this.challengeDuration = config.challengeDuration; this.chainId = config.chainId; + if ('walletClient' in config && config.walletClient) { + if (!config.walletClient.account) throw new Errors.MissingParameterError('walletClient.account'); + this.account = config.walletClient.account; + this.contractWriter = new EOAContractWriter({ + publicClient: this.publicClient, + walletClient: config.walletClient, + }); + } else if ('contractWriter' in config && config.contractWriter) { + this.contractWriter = config.contractWriter; + this.account = this.contractWriter.getAccount(); + } else { + throw new Errors.MissingParameterError('walletClient or contractWriter'); + } + this.nitroliteService = new NitroliteService( this.publicClient, this.addresses, - this.walletClient, + undefined, this.account, + this.contractWriter, ); - this.erc20Service = new Erc20Service(this.publicClient, this.walletClient); + this.erc20Service = new Erc20Service(this.publicClient, undefined, this.account, this.contractWriter); this.sharedDeps = { nitroliteService: this.nitroliteService, erc20Service: this.erc20Service, addresses: this.addresses, account: this.account, - walletClient: this.walletClient, challengeDuration: this.challengeDuration, stateSigner: this.stateSigner, chainId: this.chainId, @@ -96,27 +108,23 @@ export class NitroliteClient { const owner = this.account.address; const spender = this.addresses.custody; + const callDetails: CallsDetails = { + calls: [], + }; + if (tokenAddress !== zeroAddress) { const allowance = await this.erc20Service.getTokenAllowance(tokenAddress, owner, spender); if (allowance < amount) { - try { - const hash = await this.erc20Service.approve(tokenAddress, spender, amount); - await waitForTransaction(this.publicClient, hash); - } catch (err) { - const error = new Errors.TokenError('Failed to approve tokens for deposit'); - throw error; - } + const approveCall = this.erc20Service.prepareApproveCallParams(tokenAddress, spender, amount); + callDetails.calls.push(approveCall); } } - try { - const depositHash = await this.nitroliteService.deposit(tokenAddress, amount); - await waitForTransaction(this.publicClient, depositHash); + const depositCall = this.nitroliteService.prepareDepositCallParams(tokenAddress, amount); + callDetails.calls.push(depositCall); - return depositHash; - } catch (err) { - throw new Errors.ContractCallError('Failed to execute deposit on contract', err as Error); - } + const writeResult = await this.contractWriter.write(callDetails); + return this._getLastTxHashFromWriteResult(writeResult); } /** @@ -130,11 +138,14 @@ export class NitroliteClient { params: CreateChannelParams, ): Promise<{ channelId: ChannelId; initialState: State; txHash: Hash }> { try { - const { initialState, channelId } = await _prepareAndSignInitialState( - this.sharedDeps, - params, + const { initialState, channelId } = await _prepareAndSignInitialState(this.sharedDeps, params); + const createChannelCall = this.nitroliteService.prepareCreateChannelCallParams( + params.channel, + initialState, ); - const txHash = await this.nitroliteService.createChannel(params.channel, initialState); + + const writeResult = await this.contractWriter.write({ calls: [createChannelCall] }); + const txHash = this._getLastTxHashFromWriteResult(writeResult); return { channelId, initialState, txHash }; } catch (err) { @@ -158,30 +169,34 @@ export class NitroliteClient { try { const owner = this.account.address; const spender = this.addresses.custody; - const { initialState, channelId } = await _prepareAndSignInitialState( - this.sharedDeps, - params, - ); + const { initialState, channelId } = await _prepareAndSignInitialState(this.sharedDeps, params); + + const callDetails: CallsDetails = { + calls: [], + }; if (tokenAddress !== zeroAddress) { const allowance = await this.erc20Service.getTokenAllowance(tokenAddress, owner, spender); if (allowance < depositAmount) { - try { - const hash = await this.erc20Service.approve(tokenAddress, spender, depositAmount); - await waitForTransaction(this.publicClient, hash); - } catch (err) { - const error = new Errors.TokenError('Failed to approve tokens for deposit'); - throw error; - } + const approveCall = this.erc20Service.prepareApproveCallParams( + tokenAddress, + spender, + depositAmount, + ); + callDetails.calls.push(approveCall); } } - const txHash = await this.nitroliteService.depositAndCreateChannel( + const depositAndCreateChannelCall = this.nitroliteService.prepareDepositAndCreateChannelCallParams( tokenAddress, depositAmount, params.channel, initialState, ); + callDetails.calls.push(depositAndCreateChannelCall); + + const writeResult = await this.contractWriter.write(callDetails); + const txHash = this._getLastTxHashFromWriteResult(writeResult); return { channelId, initialState, txHash }; } catch (err) { @@ -205,7 +220,14 @@ export class NitroliteClient { } try { - return await this.nitroliteService.checkpoint(channelId, candidateState, proofStates); + const checkpointCall = this.nitroliteService.prepareCheckpointCallParams( + channelId, + candidateState, + proofStates, + ); + + const writeResult = await this.contractWriter.write({ calls: [checkpointCall] }); + return this._getLastTxHashFromWriteResult(writeResult); } catch (err) { throw new Errors.ContractCallError('Failed to execute checkpointChannel on contract', err as Error); } @@ -222,7 +244,15 @@ export class NitroliteClient { const { challengerSig } = await _prepareAndSignChallengeState(this.sharedDeps, params); try { - return await this.nitroliteService.challenge(channelId, candidateState, proofStates, challengerSig); + const challengeCall = this.nitroliteService.prepareChallengeCallParams( + channelId, + candidateState, + proofStates, + challengerSig, + ); + + const writeResult = await this.contractWriter.write({ calls: [challengeCall] }); + return this._getLastTxHashFromWriteResult(writeResult); } catch (err) { throw new Errors.ContractCallError('Failed to execute challengeChannel on contract', err as Error); } @@ -234,11 +264,19 @@ export class NitroliteClient { * @param params Parameters for resizing the channel. See {@link ResizeChannelParams}. * @returns The transaction hash. */ - async resizeChannel(params: ResizeChannelParams): Promise<{resizeState: State; txHash: Hash}> { + async resizeChannel(params: ResizeChannelParams): Promise<{ resizeState: State; txHash: Hash }> { const { resizeStateWithSigs, proofs, channelId } = await _prepareAndSignResizeState(this.sharedDeps, params); try { - return {resizeState: resizeStateWithSigs, txHash: await this.nitroliteService.resize(channelId, resizeStateWithSigs, proofs)}; + const resizeCall = this.nitroliteService.prepareResizeCallParams(channelId, resizeStateWithSigs, proofs); + + const writeResult = await this.contractWriter.write({ calls: [resizeCall] }); + const txHash = this._getLastTxHashFromWriteResult(writeResult); + + return { + resizeState: resizeStateWithSigs, + txHash, + }; } catch (err) { throw new Errors.ContractCallError('Failed to execute resizeChannel on contract', err as Error); } @@ -253,8 +291,10 @@ export class NitroliteClient { async closeChannel(params: CloseChannelParams): Promise { try { const { finalStateWithSigs, channelId } = await _prepareAndSignFinalState(this.sharedDeps, params); + const closeCall = this.nitroliteService.prepareCloseCallParams(channelId, finalStateWithSigs); - return await this.nitroliteService.close(channelId, finalStateWithSigs); + const writeResult = await this.contractWriter.write({ calls: [closeCall] }); + return this._getLastTxHashFromWriteResult(writeResult); } catch (err) { throw new Errors.ContractCallError('Failed to execute closeChannel on contract', err as Error); } @@ -269,7 +309,10 @@ export class NitroliteClient { */ async withdrawal(tokenAddress: Address, amount: bigint): Promise { try { - return await this.nitroliteService.withdraw(tokenAddress, amount); + const withdrawCall = this.nitroliteService.prepareWithdrawCallParams(tokenAddress, amount); + + const writeResult = await this.contractWriter.write({ calls: [withdrawCall] }); + return this._getLastTxHashFromWriteResult(writeResult); } catch (err) { throw new Errors.ContractCallError('Failed to execute withdrawDeposit on contract', err as Error); } @@ -281,7 +324,8 @@ export class NitroliteClient { */ async getOpenChannels(): Promise { try { - return await this.nitroliteService.getOpenChannels(this.account.address); + const accountAddress = this.account.address; + return await this.nitroliteService.getOpenChannels(accountAddress); } catch (err) { throw err; } @@ -296,10 +340,11 @@ export class NitroliteClient { async getAccountBalance(tokenAddress: Address[]): Promise; async getAccountBalance(tokenAddress: Address | Address[]): Promise { try { + const accountAddress = this.account.address; if (Array.isArray(tokenAddress)) { - return await this.nitroliteService.getAccountBalance(this.account.address, tokenAddress); + return await this.nitroliteService.getAccountBalance(accountAddress, tokenAddress); } else { - return await this.nitroliteService.getAccountBalance(this.account.address, tokenAddress); + return await this.nitroliteService.getAccountBalance(accountAddress, tokenAddress); } } catch (err) { throw err; @@ -405,4 +450,12 @@ export class NitroliteClient { ); } } + + private _getLastTxHashFromWriteResult(writeResult: WriteResult): Hash { + if (writeResult.txHashes.length < 1) { + throw new Error('No transaction hashes returned from write operation'); + } + + return writeResult.txHashes[writeResult.txHashes.length - 1]; + } } diff --git a/sdk/src/client/prepare.ts b/sdk/src/client/prepare.ts index 811a8e319..2277b1f40 100644 --- a/sdk/src/client/prepare.ts +++ b/sdk/src/client/prepare.ts @@ -1,11 +1,8 @@ import { Account, Address, - Chain, ParseAccount, SimulateContractReturnType, - Transport, - WalletClient, zeroAddress, } from 'viem'; import { ContractAddresses } from '../abis'; @@ -41,7 +38,6 @@ export interface PreparerDependencies { erc20Service: Erc20Service; addresses: ContractAddresses; account: ParseAccount; - walletClient: WalletClient>; stateSigner: StateSigner; challengeDuration: bigint; chainId: number; diff --git a/sdk/src/client/services/Erc20Service.ts b/sdk/src/client/services/Erc20Service.ts index f903acfb8..dd155d383 100644 --- a/sdk/src/client/services/Erc20Service.ts +++ b/sdk/src/client/services/Erc20Service.ts @@ -1,6 +1,8 @@ import { Account, Address, PublicClient, WalletClient, Hash } from 'viem'; import { Erc20Abi } from '../../abis/token'; import { Errors } from '../../errors'; +import { ContractCallParams, ContractWriter } from '../contract_writer/types'; +import { EOAContractWriter } from '../contract_writer/eoa'; /** * Type utility to properly type the request object from simulateContract @@ -17,7 +19,7 @@ type PreparedContractRequest = any; * @returns Promise - The transaction hash */ const executeWriteContract = async ( - walletClient: WalletClient, + contractWriter: ContractWriter, request: PreparedContractRequest, account: Account | Address, ): Promise => { @@ -29,10 +31,22 @@ const executeWriteContract = async ( // // Note: Type assertion is necessary due to viem's complex union types for transaction parameters. // The runtime behavior is correct - simulateContract returns compatible parameters for writeContract. - return walletClient.writeContract({ - ...request, - account, - } as any); + const calls = [ + { + ...request, + account, + }, + ]; + + const result = await contractWriter.write({ + calls, + }); + + if (result.txHashes.length < 1) { + throw new Error('No transaction hashes returned from write operation'); + } + + return result.txHashes[result.txHashes.length - 1]; }; /** @@ -41,26 +55,39 @@ const executeWriteContract = async ( */ export class Erc20Service { private readonly publicClient: PublicClient; - private readonly walletClient?: WalletClient; private readonly account?: Account | Address; - - constructor(publicClient: PublicClient, walletClient?: WalletClient, account?: Account | Address) { + private readonly contractWriter?: ContractWriter; + + constructor( + publicClient: PublicClient, + walletClient?: WalletClient, + account?: Account | Address, + contractWriter?: ContractWriter, + ) { if (!publicClient) { throw new Errors.MissingParameterError('publicClient'); } + if (contractWriter) { + this.contractWriter = contractWriter; + } else if (walletClient) { + this.contractWriter = new EOAContractWriter({ + publicClient, + // @ts-ignore + walletClient, + }); + } + this.publicClient = publicClient; - this.walletClient = walletClient; this.account = account || walletClient?.account; } - /** Ensures a WalletClient is available for write operations. */ - private ensureWalletClient(): WalletClient { - if (!this.walletClient) { - throw new Errors.WalletClientRequiredError(); + /** Ensures a ContractWriter is available for write operations. */ + private ensureContractWriter(): ContractWriter { + if (!this.contractWriter) { + throw new Errors.ContractWriterRequiredError(); } - - return this.walletClient; + return this.contractWriter; } /** Ensures an Account is available for write/simulation operations. */ @@ -122,6 +149,26 @@ export class Erc20Service { } } + /** + * Prepares contract call parameters for an ERC20 approve operation. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param tokenAddress Address of the ERC20 token. + * @param spender Address of the spender. + * @param amount Amount to approve. + * @returns Contract call parameters ready for execution. + */ + prepareApproveCallParams(tokenAddress: Address, spender: Address, amount: bigint): ContractCallParams { + const account = this.ensureAccount(); + + return { + address: tokenAddress, + abi: Erc20Abi, + functionName: 'approve', + args: [spender, amount], + account: account, + }; + } + /** * Prepares the request data for an ERC20 approve transaction. * Useful for batching multiple calls in a single UserOperation. @@ -133,17 +180,11 @@ export class Erc20Service { * @throws {AccountRequiredError} If no account is available for simulation. */ async prepareApprove(tokenAddress: Address, spender: Address, amount: bigint): Promise { - const account = this.ensureAccount(); const operationName = 'prepareApprove'; try { - const { request } = await this.publicClient.simulateContract({ - address: tokenAddress, - abi: Erc20Abi, - functionName: 'approve', - args: [spender, amount], - account: account, - }); + const params = this.prepareApproveCallParams(tokenAddress, spender, amount); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -165,12 +206,12 @@ export class Erc20Service { * @throws {WalletClientRequiredError | AccountRequiredError} If wallet/account is missing. */ async approve(tokenAddress: Address, spender: Address, amount: bigint): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'approve'; try { const request = await this.prepareApprove(tokenAddress, spender, amount); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { tokenAddress, spender, amount }); diff --git a/sdk/src/client/services/NitroliteService.ts b/sdk/src/client/services/NitroliteService.ts index 77a135a68..e58aa5794 100644 --- a/sdk/src/client/services/NitroliteService.ts +++ b/sdk/src/client/services/NitroliteService.ts @@ -3,6 +3,8 @@ import { custodyAbi } from '../../abis/generated'; import { ContractAddresses } from '../../abis'; import { Errors } from '../../errors'; import { Channel, ChannelData, ChannelId, Signature, State } from '../types'; +import { ContractCallParams, ContractWriter } from '../contract_writer/types'; +import { EOAContractWriter } from '../contract_writer/eoa'; /** * Type utility to properly type the request object from simulateContract @@ -24,7 +26,7 @@ type PreparedContractRequest = any; * @returns Promise - The transaction hash */ const executeWriteContract = async ( - walletClient: WalletClient, + contractWriter: ContractWriter, request: PreparedContractRequest, account: Account | Address, ): Promise => { @@ -36,10 +38,22 @@ const executeWriteContract = async ( // // Note: Type assertion is necessary due to viem's complex union types for transaction parameters. // The runtime behavior is correct - simulateContract returns compatible parameters for writeContract. - return walletClient.writeContract({ - ...request, - account, - } as any); + const calls = [ + { + ...request, + account, + }, + ]; + + const result = await contractWriter.write({ + calls, + }); + + if (result.txHashes.length < 1) { + throw new Error('No transaction hashes returned from write operation'); + } + + return result.txHashes[result.txHashes.length - 1]; }; /** @@ -48,15 +62,16 @@ const executeWriteContract = async ( */ export class NitroliteService { private readonly publicClient: PublicClient; - private readonly walletClient?: WalletClient; private readonly account?: Account | Address; private readonly addresses: ContractAddresses; + private readonly contractWriter?: ContractWriter; constructor( publicClient: PublicClient, addresses: ContractAddresses, walletClient?: WalletClient, account?: Account | Address, + contractWriter?: ContractWriter, ) { if (!publicClient) { throw new Errors.MissingParameterError('publicClient'); @@ -66,18 +81,27 @@ export class NitroliteService { throw new Errors.MissingParameterError('addresses.custody'); } + if (contractWriter) { + this.contractWriter = contractWriter; + } else if (walletClient) { + this.contractWriter = new EOAContractWriter({ + publicClient, + // @ts-ignore + walletClient, + }); + } + this.publicClient = publicClient; - this.walletClient = walletClient; this.account = account || walletClient?.account; this.addresses = addresses; } - /** Ensures a WalletClient is available for write operations. */ - private ensureWalletClient(): WalletClient { - if (!this.walletClient) { - throw new Errors.WalletClientRequiredError(); + /** Ensures a ContractWriter is available for write operations. */ + private ensureContractWriter(): ContractWriter { + if (!this.contractWriter) { + throw new Errors.ContractWriterRequiredError(); } - return this.walletClient; + return this.contractWriter; } /** Ensures an Account is available for write/simulation operations. */ @@ -127,7 +151,7 @@ export class NitroliteService { token: Address; amount: bigint; }[], - sigs: state.sigs || [] as readonly Hex[], + sigs: state.sigs || ([] as readonly Hex[]), } as const; } @@ -160,6 +184,27 @@ export class NitroliteService { }; } + /** + * Prepares contract call parameters for a deposit operation. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param tokenAddress Address of the token (use zeroAddress for ETH). + * @param amount Amount to deposit. + * @returns Contract call parameters ready for execution. + */ + prepareDepositCallParams(tokenAddress: Address, amount: bigint): ContractCallParams { + const account = this.ensureAccount(); + const accountAddress = typeof account === 'string' ? account : account.address; + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'deposit', + args: [accountAddress, tokenAddress, amount], + account: account, + value: tokenAddress === zeroAddress ? amount : 0n, + }; + } + /** * Prepares the request data for a deposit transaction. * Useful for batching multiple calls in a single UserOperation. @@ -168,19 +213,11 @@ export class NitroliteService { * @returns The prepared transaction request object. */ async prepareDeposit(tokenAddress: Address, amount: bigint): Promise { - const account = this.ensureAccount(); const operationName = 'prepareDeposit'; - const accountAddress = typeof account === 'string' ? account : account.address; try { - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'deposit', - args: [accountAddress, tokenAddress, amount], - account: account, - value: tokenAddress === zeroAddress ? amount : 0n, - }); + const params = this.prepareDepositCallParams(tokenAddress, amount); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -199,19 +236,40 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async deposit(tokenAddress: Address, amount: bigint): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'deposit'; try { const request = await this.prepareDeposit(tokenAddress, amount); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { tokenAddress, amount }); } } + /** + * Prepares contract call parameters for creating a new channel. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param channel Channel configuration. See {@link Channel} for details. + * @param initial Initial state. See {@link State} for details. + * @returns Contract call parameters ready for execution. + */ + prepareCreateChannelCallParams(channel: Channel, initial: State): ContractCallParams { + const account = this.ensureAccount(); + const abiChannel = this.convertChannelForABI(channel); + const abiState = this.convertStateForABI(initial); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'create', + args: [abiChannel, abiState], + account: account, + }; + } + /** * Prepares the request data for creating a new channel. * Useful for batching multiple calls in a single UserOperation. @@ -220,20 +278,11 @@ export class NitroliteService { * @returns The prepared transaction request object. */ async prepareCreateChannel(channel: Channel, initial: State): Promise { - const account = this.ensureAccount(); const operationName = 'prepareCreateChannel'; try { - const abiChannel = this.convertChannelForABI(channel); - const abiState = this.convertStateForABI(initial); - - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'create', - args: [abiChannel, abiState], - account: account, - }); + const params = this.prepareCreateChannelCallParams(channel, initial); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -252,19 +301,48 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async createChannel(channel: Channel, initial: State): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'createChannel'; try { const request = await this.prepareCreateChannel(channel, initial); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { channel, initial }); } } + /** + * Prepares contract call parameters for depositing funds and creating a channel in one operation. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param tokenAddress Address of the token (use zeroAddress for ETH). + * @param amount Amount to deposit. + * @param channel Channel configuration. See {@link Channel} for details. + * @param initial Initial state. See {@link State} for details. + * @returns Contract call parameters ready for execution. + */ + prepareDepositAndCreateChannelCallParams( + tokenAddress: Address, + amount: bigint, + channel: Channel, + initial: State, + ): ContractCallParams { + const account = this.ensureAccount(); + const abiChannel = this.convertChannelForABI(channel); + const abiState = this.convertStateForABI(initial); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'depositAndCreate', + args: [tokenAddress, amount, abiChannel, abiState], + account: account, + value: tokenAddress === zeroAddress ? amount : 0n, + }; + } + /** * Prepares the request data for depositing funds and creating a new channel in one operation. * Useful for batching multiple calls in a single UserOperation. @@ -280,22 +358,11 @@ export class NitroliteService { channel: Channel, initial: State, ): Promise { - const account = this.ensureAccount(); const operationName = 'prepareDepositAndCreateChannel'; - const accountAddress = typeof account === 'string' ? account : account.address; try { - const abiChannel = this.convertChannelForABI(channel); - const abiState = this.convertStateForABI(initial); - - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'depositAndCreate', - args: [tokenAddress, amount, abiChannel, abiState], - account: account, - value: tokenAddress === zeroAddress ? amount : 0n, - }); + const params = this.prepareDepositAndCreateChannelCallParams(tokenAddress, amount, channel, initial); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -321,19 +388,39 @@ export class NitroliteService { channel: Channel, initial: State, ): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'depositAndCreateChannel'; try { const request = await this.prepareDepositAndCreateChannel(tokenAddress, amount, channel, initial); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { tokenAddress, amount, channel, initial }); } } + /** + * Prepares contract call parameters for joining an existing channel. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param channelId ID of the channel. + * @param index Participant index. + * @param sig Participant signature. + * @returns Contract call parameters ready for execution. + */ + prepareJoinChannelCallParams(channelId: ChannelId, index: bigint, sig: Signature): ContractCallParams { + const account = this.ensureAccount(); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'join', + args: [channelId, index, sig], + account: account, + }; + } + /** * Prepares the request data for joining an existing channel. * Useful for batching multiple calls in a single UserOperation. @@ -343,17 +430,11 @@ export class NitroliteService { * @returns The prepared transaction request object. */ async prepareJoinChannel(channelId: ChannelId, index: bigint, sig: Signature): Promise { - const account = this.ensureAccount(); const operationName = 'prepareJoinChannel'; try { - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'join', - args: [channelId, index, sig], - account: account, - }); + const params = this.prepareJoinChannelCallParams(channelId, index, sig); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -373,19 +454,41 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async joinChannel(channelId: ChannelId, index: bigint, sig: Signature): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'joinChannel'; try { const request = await this.prepareJoinChannel(channelId, index, sig); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { channelId, index }); } } + /** + * Prepares contract call parameters for checkpointing a state. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param channelId Channel ID. See {@link ChannelId} for details. + * @param candidate State to checkpoint. See {@link State} for details. + * @param proofs Supporting proofs. See {@link State} for details. + * @returns Contract call parameters ready for execution. + */ + prepareCheckpointCallParams(channelId: ChannelId, candidate: State, proofs: State[] = []): ContractCallParams { + const account = this.ensureAccount(); + const abiCandidate = this.convertStateForABI(candidate); + const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'checkpoint', + args: [channelId, abiCandidate, abiProofs], + account: account, + }; + } + /** * Prepares the request data for checkpointing a state. * Useful for batching multiple calls in a single UserOperation. @@ -399,20 +502,11 @@ export class NitroliteService { candidate: State, proofs: State[] = [], ): Promise { - const account = this.ensureAccount(); const operationName = 'prepareCheckpoint'; try { - const abiCandidate = this.convertStateForABI(candidate); - const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); - - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'checkpoint', - args: [channelId, abiCandidate, abiProofs], - account: account, - }); + const params = this.prepareCheckpointCallParams(channelId, candidate, proofs); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -432,19 +526,47 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async checkpoint(channelId: ChannelId, candidate: State, proofs: State[] = []): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'checkpoint'; try { const request = await this.prepareCheckpoint(channelId, candidate, proofs); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { channelId }); } } + /** + * Prepares contract call parameters for challenging a state. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param channelId Channel ID. + * @param candidate State being challenged. See {@link State} for details. + * @param proofs Supporting proofs. See {@link State} for details. + * @param challengerSig Challenger signature. See {@link Signature} for details. + * @returns Contract call parameters ready for execution. + */ + prepareChallengeCallParams( + channelId: ChannelId, + candidate: State, + proofs: State[] = [], + challengerSig: Signature, + ): ContractCallParams { + const account = this.ensureAccount(); + const abiCandidate = this.convertStateForABI(candidate); + const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'challenge', + args: [channelId, abiCandidate, abiProofs, challengerSig], + account: account, + }; + } + /** * Prepares the request data for challenging a state. * Useful for batching multiple calls in a single UserOperation. @@ -460,20 +582,11 @@ export class NitroliteService { proofs: State[] = [], challengerSig: Signature, ): Promise { - const account = this.ensureAccount(); const operationName = 'prepareChallenge'; try { - const abiCandidate = this.convertStateForABI(candidate); - const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); - - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'challenge', - args: [channelId, abiCandidate, abiProofs, challengerSig], - account: account, - }); + const params = this.prepareChallengeCallParams(channelId, candidate, proofs, challengerSig); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -498,19 +611,41 @@ export class NitroliteService { proofs: State[] = [], challengerSig: Signature, ): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'challenge'; try { const request = await this.prepareChallenge(channelId, candidate, proofs, challengerSig); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { channelId }); } } + /** + * Prepares contract call parameters for resizing a channel. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param channelId Channel ID. + * @param candidate Candidate state for the resizing channel. See {@link State} for details. + * @param proofs Supporting proofs. See {@link State} for details. + * @returns Contract call parameters ready for execution. + */ + prepareResizeCallParams(channelId: ChannelId, candidate: State, proofs: State[] = []): ContractCallParams { + const account = this.ensureAccount(); + const abiCandidate = this.convertStateForABI(candidate); + const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'resize', + args: [channelId, abiCandidate, abiProofs], + account: account, + }; + } + /** * Prepares the request data for resize a channel. * Useful for batching multiple calls in a single UserOperation. @@ -524,20 +659,11 @@ export class NitroliteService { candidate: State, proofs: State[] = [], ): Promise { - const account = this.ensureAccount(); const operationName = 'prepareResize'; try { - const abiCandidate = this.convertStateForABI(candidate); - const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); - - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'resize', - args: [channelId, abiCandidate, abiProofs], - account: account, - }); + const params = this.prepareResizeCallParams(channelId, candidate, proofs); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -557,19 +683,41 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async resize(channelId: ChannelId, candidate: State, proofs: State[] = []): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'resize'; try { const request = await this.prepareResize(channelId, candidate, proofs); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { channelId }); } } + /** + * Prepares contract call parameters for closing a channel. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param channelId Channel ID. + * @param candidate Final state. See {@link State} for details. + * @param proofs Supporting proofs. See {@link State} for details. + * @returns Contract call parameters ready for execution. + */ + prepareCloseCallParams(channelId: ChannelId, candidate: State, proofs: State[] = []): ContractCallParams { + const account = this.ensureAccount(); + const abiCandidate = this.convertStateForABI(candidate); + const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'close', + args: [channelId, abiCandidate, abiProofs], + account: account, + }; + } + /** * Prepares the request data for closing a channel. * Useful for batching multiple calls in a single UserOperation. @@ -579,20 +727,11 @@ export class NitroliteService { * @returns The prepared transaction request object. */ async prepareClose(channelId: ChannelId, candidate: State, proofs: State[] = []): Promise { - const account = this.ensureAccount(); const operationName = 'prepareClose'; try { - const abiCandidate = this.convertStateForABI(candidate); - const abiProofs = proofs.map((proof) => this.convertStateForABI(proof)); - - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'close', - args: [channelId, abiCandidate, abiProofs], - account: account, - }); + const params = this.prepareCloseCallParams(channelId, candidate, proofs); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -612,19 +751,38 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async close(channelId: ChannelId, candidate: State, proofs: State[] = []): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'close'; try { const request = await this.prepareClose(channelId, candidate, proofs); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { channelId }); } } + /** + * Prepares contract call parameters for withdrawing funds. + * Returns parameters that can be used with ContractWriter for batching operations. + * @param tokenAddress Address of the token (use zeroAddress for ETH). + * @param amount Amount to withdraw. + * @returns Contract call parameters ready for execution. + */ + prepareWithdrawCallParams(tokenAddress: Address, amount: bigint): ContractCallParams { + const account = this.ensureAccount(); + + return { + address: this.custodyAddress, + abi: custodyAbi, + functionName: 'withdraw', + args: [tokenAddress, amount], + account: account, + }; + } + /** * Prepares the request data for withdrawing funds. * Useful for batching multiple calls in a single UserOperation. @@ -633,17 +791,11 @@ export class NitroliteService { * @returns The prepared transaction request object. */ async prepareWithdraw(tokenAddress: Address, amount: bigint): Promise { - const account = this.ensureAccount(); const operationName = 'prepareWithdraw'; try { - const { request } = await this.publicClient.simulateContract({ - address: this.custodyAddress, - abi: custodyAbi, - functionName: 'withdraw', - args: [tokenAddress, amount], - account: account, - }); + const params = this.prepareWithdrawCallParams(tokenAddress, amount); + const { request } = await this.publicClient.simulateContract(params); return request; } catch (error: any) { @@ -662,13 +814,13 @@ export class NitroliteService { * @error Throws ContractCallError | TransactionError */ async withdraw(tokenAddress: Address, amount: bigint): Promise { - const walletClient = this.ensureWalletClient(); + const contractWriter = this.ensureContractWriter(); const account = this.ensureAccount(); const operationName = 'withdraw'; try { const request = await this.prepareWithdraw(tokenAddress, amount); - return await executeWriteContract(walletClient, request, account); + return await executeWriteContract(contractWriter, request, account); } catch (error: any) { if (error instanceof Errors.NitroliteError) throw error; throw new Errors.TransactionError(operationName, error, { tokenAddress, amount }); diff --git a/sdk/src/client/signer.ts b/sdk/src/client/signer.ts index a8981b0cb..60c41978b 100644 --- a/sdk/src/client/signer.ts +++ b/sdk/src/client/signer.ts @@ -52,7 +52,7 @@ export class WalletStateSigner implements StateSigner { } async signState(channelId: Hex, state: State): Promise { - const packedState = getPackedState(channelId, state) + const packedState = getPackedState(channelId, state); return this.walletClient.signMessage({ message: { raw: packedState } }); } @@ -62,6 +62,42 @@ export class WalletStateSigner implements StateSigner { } } +/** + * Implementation of the StateSigner interface using a viem Account. + * This class uses the account to sign states and raw messages. + * It is suitable for use in scenarios where the account is available and can sign messages, + * e.g. signing with a private key or other account providers. + */ +export class AccountStateSigner implements StateSigner { + private readonly account: Account; + + constructor(account: Account) { + this.account = account; + } + + getAddress(): Address { + return this.account.address; + } + + async signState(channelId: Hex, state: State): Promise { + if (!this.account.signMessage) { + throw new Error('Account does not support message signing'); + } + + const packedState = getPackedState(channelId, state); + + return this.account.signMessage({ message: { raw: packedState } }); + } + + async signRawMessage(message: Hex): Promise { + if (!this.account.signMessage) { + throw new Error('Account does not support message signing'); + } + + return this.account.signMessage({ message: { raw: message } }); + } +} + /** * Implementation of the StateSigner interface using a session key. * This class uses a session key to sign states and raw messages. diff --git a/sdk/src/client/state.ts b/sdk/src/client/state.ts index 65f3f99a7..4d753f9e1 100644 --- a/sdk/src/client/state.ts +++ b/sdk/src/client/state.ts @@ -1,11 +1,8 @@ import { Address, zeroAddress } from 'viem'; import * as Errors from '../errors'; -import { - getChannelId, - getPackedChallengeState, -} from '../utils'; +import { getChannelId, getPackedChallengeState } from '../utils'; import { PreparerDependencies } from './prepare'; -import { StateSigner, WalletStateSigner } from './signer'; +import { AccountStateSigner, SessionKeyStateSigner, StateSigner, WalletStateSigner } from './signer'; import { ChallengeChannelParams, ChannelId, @@ -185,7 +182,9 @@ export async function _prepareAndSignFinalState( * @returns A StateSigner object depending on the user participant address. */ async function _fetchParticipantAndGetSigner(deps: PreparerDependencies, channelId: ChannelId): Promise { - const {channel: {participants}} = await deps.nitroliteService.getChannelData(channelId); + const { + channel: { participants }, + } = await deps.nitroliteService.getChannelData(channelId); let participant = participants.length == 2 ? participants[0] : zeroAddress; return _checkParticipantAndGetSigner(deps, participant); @@ -200,9 +199,8 @@ async function _fetchParticipantAndGetSigner(deps: PreparerDependencies, channel */ function _checkParticipantAndGetSigner(deps: PreparerDependencies, participant: Address): StateSigner { let signer = deps.stateSigner; - if (participant == deps.walletClient.account.address) { - signer = new WalletStateSigner(deps.walletClient); + if (participant == deps.account.address && signer instanceof SessionKeyStateSigner) { + signer = new AccountStateSigner(deps.account); } - return signer; } diff --git a/sdk/src/client/types.ts b/sdk/src/client/types.ts index ffc72abe8..45bfa95b4 100644 --- a/sdk/src/client/types.ts +++ b/sdk/src/client/types.ts @@ -1,6 +1,7 @@ import { Account, Hex, PublicClient, WalletClient, Chain, Transport, ParseAccount, Address } from 'viem'; import { ContractAddresses } from '../abis'; import { StateSigner } from './signer'; +import { ContractWriter } from './contract_writer/types'; /** * Channel identifier @@ -92,22 +93,12 @@ export interface FinalState extends UnsignedState { } /** - * Configuration for initializing the NitroliteClient. + * Base configuration shared by both NitroliteClient config variants. */ -export interface NitroliteClientConfig { +interface BaseNitroliteClientConfig { /** The viem PublicClient for reading blockchain data. */ publicClient: PublicClient; - /** - * The viem WalletClient used for: - * 1. Sending on-chain transactions in direct execution methods (e.g., `client.deposit`). - * 2. Providing the 'account' context for transaction preparation (`client.txPreparer`). - * 3. Signing off-chain states *if* `stateWalletClient` is not provided. - * @dev Note that the client's `signMessage` function should NOT add an EIP-191 prefix to the message signed. See {@link SignMessageFn} for details. - * viem's `signMessage` can operate in `raw` mode, which suffice. - */ - walletClient: WalletClient>; - /** * Implementation of the StateSigner interface used for signing protocol states. */ @@ -123,6 +114,40 @@ export interface NitroliteClientConfig { challengeDuration: bigint; } +/** + * Configuration with WalletClient for EOA-based transaction execution. + * @deprecated Legacy configuration, left for backward compatibility. Use NitroliteClientConfigWithContractWriter instead. + */ +interface LegacyNitroliteClientConfigWithWalletClient extends BaseNitroliteClientConfig { + /** + * The viem WalletClient used for: + * 1. Sending on-chain transactions in direct execution methods (e.g., `client.deposit`). + * 2. Providing the 'account' context for transaction preparation (`client.txPreparer`). + * 3. Signing off-chain states *if* `stateWalletClient` is not provided. + * @dev Note that the client's `signMessage` function should NOT add an EIP-191 prefix to the message signed. See {@link SignMessageFn} for details. + * viem's `signMessage` can operate in `raw` mode, which suffice. + */ + walletClient: WalletClient>; + contractWriter?: never; +} + +/** + * Configuration with ContractWriter for custom transaction execution (e.g., Account Abstraction). + */ +interface NitroliteClientConfigWithContractWriter extends BaseNitroliteClientConfig { + walletClient?: never; + /** + * ContractWriter instance for writing contract transactions. + */ + contractWriter: ContractWriter; +} + +/** + * Configuration for initializing the NitroliteClient. + * Must provide either walletClient or contractWriter, but not both. + */ +export type NitroliteClientConfig = LegacyNitroliteClientConfigWithWalletClient | NitroliteClientConfigWithContractWriter; + /** * Parameters required for creating a new state channel. * @remarks diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 32908360a..5a7253db0 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -188,6 +188,19 @@ export class WalletClientRequiredError extends AuthenticationError { } } +export class ContractWriterRequiredError extends AuthenticationError { + constructor(details?: Record, cause?: Error) { + super( + 'ContractWriter instance is required for this operation', + 'CONTRACT_WRITER_REQUIRED', + 400, + 'Provide a valid ContractWriter instance during service initialization', + details, + cause, + ); + } +} + export class AccountRequiredError extends AuthenticationError { constructor(details?: Record, cause?: Error) { super( @@ -369,6 +382,7 @@ export const Errors = { UnauthorizedError, NotParticipantError, WalletClientRequiredError, + ContractWriterRequiredError, AccountRequiredError, ContractNotFoundError, diff --git a/sdk/test/unit/client/index.test.ts b/sdk/test/unit/client/index.test.ts index f5160e3b3..9cf675ba7 100644 --- a/sdk/test/unit/client/index.test.ts +++ b/sdk/test/unit/client/index.test.ts @@ -3,22 +3,19 @@ import { NitroliteClient } from '../../../src/client/index'; import { Errors } from '../../../src/errors'; import { Address, Hash, Hex } from 'viem'; import * as stateModule from '../../../src/client/state'; -import { - Allocation, - ChannelId, - ChannelStatus, - CreateChannelParams, - StateIntent, -} from '../../../src/client/types'; +import { Allocation, ChannelId, ChannelStatus, CreateChannelParams, StateIntent } from '../../../src/client/types'; describe('NitroliteClient', () => { let client: NitroliteClient; const mockPublicClient = { waitForTransactionReceipt: jest.fn(() => Promise.resolve({ status: 'success' })), } as any; - const mockAccount = { address: '0x1234567890123456789012345678901234567890' as Address }; - const mockSignature = '0x' + '1234567890abcdef'.repeat(8) + '1b'; // 128 hex chars, v = 27 const mockSignMessage = jest.fn(() => Promise.resolve(mockSignature)); + const mockAccount = { + address: '0x1234567890123456789012345678901234567890' as Address, + signMessage: mockSignMessage, + }; + const mockSignature = '0x' + '1234567890abcdef'.repeat(8) + '1b'; // 128 hex chars, v = 27 const mockWalletClient = { account: mockAccount, signMessage: mockSignMessage, @@ -34,15 +31,20 @@ describe('NitroliteClient', () => { let mockNitroService: any; let mockErc20Service: any; + let mockContractWriter: any; const stateSigner = { getAddress: jest.fn(() => mockAccount.address), signState: jest.fn(async (_1: Hex, _2: any) => mockSignature as Hex), signRawMessage: jest.fn(async (_: Hex) => mockSignature as Hex), - } + }; beforeEach(() => { jest.restoreAllMocks(); + mockContractWriter = { + // @ts-ignore + write: jest.fn().mockResolvedValue({ txHashes: ['0xTXHASH' as Hash] }), + }; client = new NitroliteClient({ publicClient: mockPublicClient, walletClient: mockWalletClient, @@ -52,29 +54,32 @@ describe('NitroliteClient', () => { stateSigner, }); mockNitroService = { - deposit: jest.fn(), - createChannel: jest.fn(), - depositAndCreateChannel: jest.fn(), - checkpoint: jest.fn(), - challenge: jest.fn(), - close: jest.fn(), - withdraw: jest.fn(), + prepareDepositCallParams: jest.fn(), + prepareCreateChannelCallParams: jest.fn(), + prepareDepositAndCreateChannelCallParams: jest.fn(), + prepareCheckpointCallParams: jest.fn(), + prepareChallengeCallParams: jest.fn(), + prepareResizeCallParams: jest.fn(), + prepareCloseCallParams: jest.fn(), + prepareWithdrawCallParams: jest.fn(), getOpenChannels: jest.fn(), getAccountBalance: jest.fn(), getChannelBalance: jest.fn(), getChannelData: jest.fn(), - prepareDepositAndCreateChannel: jest.fn(), }; mockErc20Service = { getTokenAllowance: jest.fn(), + prepareApproveCallParams: jest.fn(), approve: jest.fn(), getTokenBalance: jest.fn(), }; - // override private services + // override private services and contractWriter // @ts-ignore client.nitroliteService = mockNitroService; // @ts-ignore client.erc20Service = mockErc20Service; + // @ts-ignore + client.contractWriter = mockContractWriter; // also override sharedDeps to use mock services // @ts-ignore client.sharedDeps.nitroliteService = mockNitroService; @@ -85,7 +90,7 @@ describe('NitroliteClient', () => { describe('deposit', () => { test('ERC20 no approval needed', async () => { mockErc20Service.getTokenAllowance.mockResolvedValue(100n); - mockNitroService.deposit.mockResolvedValue('0xDEP' as Hash); + mockNitroService.prepareDepositCallParams.mockReturnValue({ fn: 'deposit' }); const tx = await client.deposit(tokenAddress, 50n); @@ -94,33 +99,41 @@ describe('NitroliteClient', () => { mockAccount.address, mockAddresses.custody, ); - expect(mockNitroService.deposit).toHaveBeenCalledWith(tokenAddress, 50n); - expect(tx).toBe('0xDEP'); + expect(mockNitroService.prepareDepositCallParams).toHaveBeenCalledWith(tokenAddress, 50n); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'deposit' }] }); + expect(tx).toBe('0xTXHASH'); }); test('ERC20 needs approval', async () => { mockErc20Service.getTokenAllowance.mockResolvedValue(10n); - mockErc20Service.approve.mockResolvedValue('0xAPP' as Hash); - mockNitroService.deposit.mockResolvedValue('0xDEP' as Hash); + mockErc20Service.prepareApproveCallParams.mockReturnValue({ fn: 'approve' }); + mockNitroService.prepareDepositCallParams.mockReturnValue({ fn: 'deposit' }); const tx = await client.deposit(tokenAddress, 50n); - expect(mockErc20Service.approve).toHaveBeenCalledWith(tokenAddress, mockAddresses.custody, 50n); - expect(tx).toBe('0xDEP'); + expect(mockErc20Service.prepareApproveCallParams).toHaveBeenCalledWith( + tokenAddress, + mockAddresses.custody, + 50n, + ); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'approve' }, { fn: 'deposit' }] }); + expect(tx).toBe('0xTXHASH'); }); test('approve failure throws TokenError', async () => { mockErc20Service.getTokenAllowance.mockResolvedValue(0n); - mockErc20Service.approve.mockRejectedValue(new Error('fail')); + mockErc20Service.prepareApproveCallParams.mockReturnValue({ fn: 'approve' }); + mockContractWriter.write.mockRejectedValue(new Error('fail')); - await expect(client.deposit(tokenAddress, 10n)).rejects.toThrow(Errors.TokenError); + await expect(client.deposit(tokenAddress, 10n)).rejects.toThrow(Error); }); test('deposit failure throws ContractCallError', async () => { mockErc20Service.getTokenAllowance.mockResolvedValue(100n); - mockNitroService.deposit.mockRejectedValue(new Error('fail')); + mockNitroService.prepareDepositCallParams.mockReturnValue({ fn: 'deposit' }); + mockContractWriter.write.mockRejectedValue(new Error('fail')); - await expect(client.deposit(tokenAddress, 10n)).rejects.toThrow(Errors.ContractCallError); + await expect(client.deposit(tokenAddress, 10n)).rejects.toThrow(Error); }); }); @@ -156,20 +169,21 @@ describe('NitroliteClient', () => { const initialState = { ...params.unsignedInitialState, sigs: ['0xaccSig', '0xSRVSIG'] as Hex[], - } + }; const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; - jest.spyOn(stateModule, '_prepareAndSignInitialState').mockResolvedValue({initialState, channelId}); - mockNitroService.createChannel.mockResolvedValue('0xCRE' as Hash); + jest.spyOn(stateModule, '_prepareAndSignInitialState').mockResolvedValue({ initialState, channelId }); + mockNitroService.prepareCreateChannelCallParams.mockReturnValue({ fn: 'createChannel' }); const result = await client.createChannel(params); expect(stateModule._prepareAndSignInitialState).toHaveBeenCalledWith(expect.anything(), params); - expect(mockNitroService.createChannel).toHaveBeenCalledWith(params.channel, initialState); + expect(mockNitroService.prepareCreateChannelCallParams).toHaveBeenCalledWith(params.channel, initialState); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'createChannel' }] }); expect(result).toEqual({ channelId, initialState, - txHash: '0xCRE', + txHash: '0xTXHASH', }); }); @@ -190,20 +204,22 @@ describe('NitroliteClient', () => { sigs: [], }; + mockErc20Service.getTokenAllowance.mockResolvedValue(100n); jest.spyOn(stateModule, '_prepareAndSignInitialState').mockResolvedValue({ initialState, channelId, }); - mockNitroService.depositAndCreateChannel.mockResolvedValue('0xDEPandCRE' as Hash); + mockNitroService.prepareDepositAndCreateChannelCallParams.mockReturnValue({ fn: 'depositAndCreate' }); const res = await client.depositAndCreateChannel(tokenAddress, 10n, { initialAllocationAmounts: [1n, 2n], stateData: '0x00' as any, } as any); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'depositAndCreate' }] }); expect(res).toEqual({ channelId, initialState, - txHash: '0xDEPandCRE' as Hash, + txHash: '0xTXHASH' as Hash, }); }); }); @@ -215,15 +231,16 @@ describe('NitroliteClient', () => { candidateState: { sigs: ['s1', 's2'] } as any, proofStates: [], }; - mockNitroService.checkpoint.mockResolvedValue('0xCHK' as Hash); + mockNitroService.prepareCheckpointCallParams.mockReturnValue({ fn: 'checkpoint' }); const tx = await client.checkpointChannel(params); - expect(mockNitroService.checkpoint).toHaveBeenCalledWith( + expect(mockNitroService.prepareCheckpointCallParams).toHaveBeenCalledWith( params.channelId, params.candidateState, params.proofStates, ); - expect(tx).toBe('0xCHK'); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'checkpoint' }] }); + expect(tx).toBe('0xTXHASH'); }); test('insufficient sigs throws InvalidParameterError', async () => { @@ -250,7 +267,7 @@ describe('NitroliteClient', () => { challengeExpiry: 0n, lastValidState: {} as any, }); - mockNitroService.challenge.mockResolvedValue('0xCHL' as Hash); + mockNitroService.prepareChallengeCallParams.mockReturnValue({ fn: 'challenge' }); const params = { channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, candidateState: { @@ -274,13 +291,14 @@ describe('NitroliteClient', () => { proofStates: [], }; const tx = await client.challengeChannel(params); - expect(mockNitroService.challenge).toHaveBeenCalledWith( + expect(mockNitroService.prepareChallengeCallParams).toHaveBeenCalledWith( params.channelId, params.candidateState, params.proofStates, mockSignature, // the signature ); - expect(tx).toBe('0xCHL'); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'challenge' }] }); + expect(tx).toBe('0xTXHASH'); }); test('failure throws ContractCallError', async () => { @@ -320,7 +338,8 @@ describe('NitroliteClient', () => { lastValidState: {} as any, }); // But make challenge fail - mockNitroService.challenge.mockRejectedValue(new Error('fail')); + mockNitroService.prepareChallengeCallParams.mockReturnValue({ fn: 'challenge' }); + mockContractWriter.write.mockRejectedValue(new Error('fail')); await expect(client.challengeChannel(params)).rejects.toThrow(Errors.ContractCallError); }); }); @@ -331,7 +350,7 @@ describe('NitroliteClient', () => { finalStateWithSigs: {} as any, channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, }); - mockNitroService.close.mockResolvedValue('0xCLS' as Hash); + mockNitroService.prepareCloseCallParams.mockReturnValue({ fn: 'close' }); const tx = await client.closeChannel({ finalState: { @@ -342,11 +361,12 @@ describe('NitroliteClient', () => { } as any, }); expect(stateModule._prepareAndSignFinalState).toHaveBeenCalledWith(expect.anything(), expect.any(Object)); - expect(mockNitroService.close).toHaveBeenCalledWith( + expect(mockNitroService.prepareCloseCallParams).toHaveBeenCalledWith( '0x0000000000000000000000000000000000000000000000000000000000000001', {} as any, ); - expect(tx).toBe('0xCLS'); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'close' }] }); + expect(tx).toBe('0xTXHASH'); }); test('failure throws ContractCallError', async () => { @@ -363,14 +383,16 @@ describe('NitroliteClient', () => { describe('withdrawal', () => { test('success', async () => { - mockNitroService.withdraw.mockResolvedValue('0xWDL' as Hash); + mockNitroService.prepareWithdrawCallParams.mockReturnValue({ fn: 'withdraw' }); const tx = await client.withdrawal(tokenAddress, 20n); - expect(mockNitroService.withdraw).toHaveBeenCalledWith(tokenAddress, 20n); - expect(tx).toBe('0xWDL'); + expect(mockNitroService.prepareWithdrawCallParams).toHaveBeenCalledWith(tokenAddress, 20n); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ fn: 'withdraw' }] }); + expect(tx).toBe('0xTXHASH'); }); test('failure throws ContractCallError', async () => { - mockNitroService.withdraw.mockRejectedValue(new Error('fail')); + mockNitroService.prepareWithdrawCallParams.mockReturnValue({ fn: 'withdraw' }); + mockContractWriter.write.mockRejectedValue(new Error('fail')); await expect(client.withdrawal(tokenAddress, 20n)).rejects.toThrow(Errors.ContractCallError); }); }); diff --git a/sdk/test/unit/client/services/Erc20Service.test.ts b/sdk/test/unit/client/services/Erc20Service.test.ts index 3a446bb09..27a14cd1b 100644 --- a/sdk/test/unit/client/services/Erc20Service.test.ts +++ b/sdk/test/unit/client/services/Erc20Service.test.ts @@ -17,6 +17,7 @@ describe('Erc20Service', () => { mockPublicClient = { readContract: jest.fn(), simulateContract: jest.fn(), + waitForTransactionReceipt: jest.fn((_: any) => ({ status: 'success' })), }; mockWalletClient = { writeContract: jest.fn(), @@ -120,9 +121,9 @@ describe('Erc20Service', () => { expect(result).toBe('0xhash'); }); - test('should throw WalletClientRequiredError if walletClient missing', async () => { + test('should throw ContractWriterRequiredError if walletClient missing', async () => { const svc = new Erc20Service(mockPublicClient as any, undefined, account); - await expect(svc.approve(tokenAddress, spender, 123n)).rejects.toThrow(Errors.WalletClientRequiredError); + await expect(svc.approve(tokenAddress, spender, 123n)).rejects.toThrow(Errors.ContractWriterRequiredError); }); test('should throw TransactionError on error', async () => { diff --git a/sdk/test/unit/client/services/NitroliteService.test.ts b/sdk/test/unit/client/services/NitroliteService.test.ts index a6ff910ef..39176a24c 100644 --- a/sdk/test/unit/client/services/NitroliteService.test.ts +++ b/sdk/test/unit/client/services/NitroliteService.test.ts @@ -25,6 +25,7 @@ describe('NitroliteService', () => { let mockPublicClient: any; let mockWalletClient: any; + let mockContractWriter: any; let service: NitroliteService; beforeEach(() => { @@ -36,7 +37,10 @@ describe('NitroliteService', () => { writeContract: jest.fn(), account, }; - service = new NitroliteService(mockPublicClient, addresses, mockWalletClient, account); + mockContractWriter = { + write: jest.fn(), + }; + service = new NitroliteService(mockPublicClient, addresses, mockWalletClient, account, mockContractWriter); }); describe('constructor', () => { @@ -156,21 +160,21 @@ describe('NitroliteService', () => { test('success', async () => { const req = fakeRequest(); (mockPublicClient.simulateContract as any).mockResolvedValue({ request: req, result: {} }); - (mockWalletClient.writeContract as any).mockResolvedValue('0xhash'); + (mockContractWriter.write as any).mockResolvedValue({ txHashes: ['0xhash'] }); const hash = await def.exec(); - expect(mockWalletClient.writeContract).toHaveBeenCalledWith({ ...req, account }); + expect(mockContractWriter.write).toHaveBeenCalledWith({ calls: [{ ...req, account }] }); expect(hash).toBe('0xhash'); }); test('TransactionError', async () => { const req = fakeRequest(); (mockPublicClient.simulateContract as any).mockResolvedValue({ request: req, result: {} }); - (mockWalletClient.writeContract as any).mockRejectedValue(new Error('oops')); + (mockContractWriter.write as any).mockRejectedValue(new Error('oops')); await expect(def.exec()).rejects.toThrow(Errors.TransactionError); }); test('rethrow NitroliteError', async () => { (mockPublicClient.simulateContract as any).mockResolvedValue({ request: {} as any, result: {} }); - const ne = new Errors.WalletClientRequiredError(); - (mockWalletClient.writeContract as any).mockRejectedValue(ne); + const ne = new Errors.ContractWriterRequiredError(); + (mockContractWriter.write as any).mockRejectedValue(ne); await expect(def.exec()).rejects.toThrow(ne); }); }); diff --git a/sdk/test/unit/client/state.test.ts b/sdk/test/unit/client/state.test.ts index c9d6228fa..e525b9335 100644 --- a/sdk/test/unit/client/state.test.ts +++ b/sdk/test/unit/client/state.test.ts @@ -34,7 +34,10 @@ describe('_prepareAndSignInitialState', () => { beforeEach(() => { deps = { - account: { address: '0xOWNER' as Hex }, + account: { + address: '0xOWNER' as Hex, + signMessage: stateSigner.signRawMessage, + }, stateSigner, walletClient: { account: { address: '0xWALLET' as Hex }, @@ -49,7 +52,7 @@ describe('_prepareAndSignInitialState', () => { }; defaultChannel = { - participants: [deps.account.address, guestAddress], + participants: ['0xWALLET', guestAddress], adjudicator: adjudicatorAddress, challenge: challengeDuration, nonce: 999n, @@ -90,16 +93,13 @@ describe('_prepareAndSignInitialState', () => { sigs: ['accSig', '0xSRVSIG'], }); // Signs the state - expect(stateSigner.signState).toHaveBeenCalledWith( - 'cid', - { - data: '0xcustomData', - intent: StateIntent.INITIALIZE, - allocations: expect.any(Array), - version: 0n, - sigs: [], - } - ); + expect(stateSigner.signState).toHaveBeenCalledWith('cid', { + data: '0xcustomData', + intent: StateIntent.INITIALIZE, + allocations: expect.any(Array), + version: 0n, + sigs: [], + }); }); test('throws if no adjudicator', async () => { @@ -205,16 +205,13 @@ describe('_prepareAndSignFinalState', () => { version, sigs: ['accSig', 'srvSig'], }); - expect(stateSigner.signState).toHaveBeenCalledWith( - 'cid', - { - data: 'finalData', - intent: StateIntent.FINALIZE, - allocations, - version, - sigs: [], - } - ); + expect(stateSigner.signState).toHaveBeenCalledWith('cid', { + data: 'finalData', + intent: StateIntent.FINALIZE, + allocations, + version, + sigs: [], + }); }); test('throws if no stateData', async () => {