From 480e2ed994c98923e4b24414e1bf0d9a68392727 Mon Sep 17 00:00:00 2001 From: MaxMoskalenko Date: Wed, 30 Jul 2025 18:06:14 +0300 Subject: [PATCH 1/4] feat: replace stateWalletClient with StateSigner --- sdk/src/client/index.ts | 8 +-- sdk/src/client/prepare.ts | 3 +- sdk/src/client/signer.ts | 92 ++++++++++++++++++++++++++++++ sdk/src/client/state.ts | 16 +++--- sdk/src/client/types.ts | 10 +--- sdk/src/rpc/api.ts | 9 +-- sdk/src/utils/sign.ts | 9 +++ sdk/src/utils/state.ts | 63 +++----------------- sdk/test/unit/client/index.test.ts | 8 ++- sdk/test/unit/client/state.test.ts | 60 +++++++++++-------- sdk/test/unit/utils/state.test.ts | 36 +----------- 11 files changed, 171 insertions(+), 143 deletions(-) create mode 100644 sdk/src/client/signer.ts create mode 100644 sdk/src/utils/sign.ts diff --git a/sdk/src/client/index.ts b/sdk/src/client/index.ts index 8afe54915..0dae6edf3 100644 --- a/sdk/src/client/index.ts +++ b/sdk/src/client/index.ts @@ -21,6 +21,7 @@ import { ResizeChannelParams, State, } from './types'; +import { StateSigner } from './signer'; const CUSTODY_MIN_CHALLENGE_DURATION = 3600n; @@ -36,7 +37,7 @@ export class NitroliteClient { public readonly challengeDuration: bigint; public readonly txPreparer: NitroliteTransactionPreparer; public readonly chainId: number; - private readonly stateWalletClient: WalletClient>; + private readonly stateSigner: StateSigner; private readonly nitroliteService: NitroliteService; private readonly erc20Service: Erc20Service; private readonly sharedDeps: PreparerDependencies; @@ -57,8 +58,7 @@ export class NitroliteClient { this.publicClient = config.publicClient; this.walletClient = config.walletClient; - // Determine which wallet client to use for state signing - this.stateWalletClient = config.stateWalletClient ?? config.walletClient; + this.stateSigner = config.stateSigner; this.account = config.walletClient.account; this.addresses = config.addresses; this.challengeDuration = config.challengeDuration; @@ -79,7 +79,7 @@ export class NitroliteClient { account: this.account, walletClient: this.walletClient, challengeDuration: this.challengeDuration, - stateWalletClient: this.stateWalletClient, + stateSigner: this.stateSigner, chainId: this.chainId, }; diff --git a/sdk/src/client/prepare.ts b/sdk/src/client/prepare.ts index a0cda7c40..275f4a5fb 100644 --- a/sdk/src/client/prepare.ts +++ b/sdk/src/client/prepare.ts @@ -24,6 +24,7 @@ import { CreateChannelParams, ResizeChannelParams, } from './types'; +import { StateSigner } from './signer'; /** * Represents the data needed to construct a transaction or UserOperation call. @@ -41,7 +42,7 @@ export interface PreparerDependencies { addresses: ContractAddresses; account: ParseAccount; walletClient: WalletClient>; - stateWalletClient: WalletClient>; + stateSigner: StateSigner; challengeDuration: bigint; chainId: number; } diff --git a/sdk/src/client/signer.ts b/sdk/src/client/signer.ts new file mode 100644 index 000000000..dfe3631ab --- /dev/null +++ b/sdk/src/client/signer.ts @@ -0,0 +1,92 @@ +import { Account, Address, Chain, Hex, ParseAccount, toHex, Transport, WalletClient } from 'viem'; +import { State } from './types'; +import { getStateHash } from '../utils'; +import { signRawECDSAMessage } from '../utils/sign'; +import { privateKeyToAccount } from 'viem/accounts'; + +// TODO: perhaps extend this interface with rpc signing methods and use it as universal signer interface + +/** + * Interface for signing protocol states. + * This interface is used to abstract the signing logic for state updates in the Nitrolite SDK. + * It allows for different implementations, such as using a wallet client or a session key. + * Also implementation could include data packing/encoding, which is crucial for some signatures (EIP-712, EIP-191) + */ +export interface StateSigner { + /** + * Get the address of the signer. + * @returns The address of the signer as a Promise. + */ + getAddress(): Promise
; + /** + * Sign a state for a given channel ID. + * @param channelId The ID of the channel. + * @param state The state to sign. + * @returns A Promise that resolves to the signature as a Hex string. + */ + signState(channelId: Hex, state: State): Promise; + /** + * Sign a raw message. + * @param message The message to sign as a Hex string. + * @returns A Promise that resolves to the signature as a Hex string. + * @dev use viem's `toHex` to convert the message to Hex if needed. + */ + signRawMessage(message: Hex): Promise; +} + +/** + * Implementation of the StateSigner interface using a viem WalletClient. + * This class uses the wallet client to sign states and raw messages. + * It is suitable for use in scenarios where the wallet client is available and can sign messages, + * e.g. signing with MetaMask or other wallet providers. + */ +export class WalletStateSigner implements StateSigner { + private readonly walletClient: WalletClient>; + + constructor(walletClient: WalletClient>) { + this.walletClient = walletClient; + } + + async getAddress(): Promise
{ + return this.walletClient.account.address; + } + + async signState(channelId: Hex, state: State): Promise { + const stateHash = getStateHash(channelId, state); + + return this.walletClient.signMessage({ message: { raw: stateHash } }); + } + + async signRawMessage(message: Hex): Promise { + return this.walletClient.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. + * It is suitable for scenarios where a session key is used for signing and private key could be exposed to application. + */ +export class SessionKeyStateSigner implements StateSigner { + private readonly sessionKey: Hex; + private readonly account: Account; + + constructor(sessionKey: Hex) { + this.sessionKey = sessionKey; + this.account = privateKeyToAccount(sessionKey); + } + + async getAddress(): Promise
{ + return this.account.address; + } + + async signState(channelId: Hex, state: State): Promise { + const stateHash = getStateHash(channelId, state); + + return signRawECDSAMessage(stateHash, this.sessionKey); + } + + async signRawMessage(message: Hex): Promise { + return signRawECDSAMessage(message, this.sessionKey); + } +} diff --git a/sdk/src/client/state.ts b/sdk/src/client/state.ts index 40878f2a7..b842f285b 100644 --- a/sdk/src/client/state.ts +++ b/sdk/src/client/state.ts @@ -1,6 +1,6 @@ -import { Address, encodeAbiParameters, encodePacked, Hex, keccak256 } from 'viem'; +import { Address, Hex } from 'viem'; import * as Errors from '../errors'; -import { generateChannelNonce, getChannelId, getPackedState, getStateHash, signChallengeState, signState } from '../utils'; +import { generateChannelNonce, getChallengeHash, getChannelId } from '../utils'; import { PreparerDependencies } from './prepare'; import { ChallengeChannelParams, @@ -38,7 +38,7 @@ export async function _prepareAndSignInitialState( const channelNonce = generateChannelNonce(deps.account.address); const participants: [Hex, Hex] = [deps.account.address, deps.addresses.guestAddress]; - const channelParticipants: [Hex, Hex] = [deps.stateWalletClient.account.address, deps.addresses.guestAddress]; + const channelParticipants: [Hex, Hex] = [await deps.stateSigner.getAddress(), deps.addresses.guestAddress]; const adjudicatorAddress = deps.addresses.adjudicator; if (!adjudicatorAddress) { throw new Errors.MissingParameterError( @@ -77,8 +77,7 @@ export async function _prepareAndSignInitialState( sigs: [], }; - - const accountSignature = await signState(channelId, stateToSign, deps.stateWalletClient.signMessage); + const accountSignature = await deps.stateSigner.signState(channelId, stateToSign); const initialState: State = { ...stateToSign, sigs: [accountSignature], @@ -104,7 +103,8 @@ export async function _prepareAndSignChallengeState( challengerSig: Signature; }> { const { channelId, candidateState, proofStates = [] } = params; - const challengerSig = await signChallengeState(channelId, candidateState, deps.stateWalletClient.signMessage); + const challengeHash = await getChallengeHash(channelId, candidateState); + const challengerSig = await deps.stateSigner.signRawMessage(challengeHash); return { channelId, candidateState, proofs: proofStates, challengerSig }; } @@ -136,7 +136,7 @@ export async function _prepareAndSignResizeState( sigs: [], }; - const accountSignature = await signState(channelId, stateToSign, deps.stateWalletClient.signMessage); + const accountSignature = await deps.stateSigner.signState(channelId, stateToSign); // Create a new state with signatures in the requested style const resizeStateWithSigs: State = { @@ -176,7 +176,7 @@ export async function _prepareAndSignFinalState( sigs: [], }; - const accountSignature = await signState(channelId, stateToSign, deps.stateWalletClient.signMessage); + const accountSignature = await deps.stateSigner.signState(channelId, stateToSign); // Create a new state with signatures in the requested style const finalStateWithSigs: State = { diff --git a/sdk/src/client/types.ts b/sdk/src/client/types.ts index 2bb5171ea..16edc1961 100644 --- a/sdk/src/client/types.ts +++ b/sdk/src/client/types.ts @@ -1,5 +1,6 @@ import { Account, Hex, PublicClient, WalletClient, Chain, Transport, ParseAccount, Address } from 'viem'; import { ContractAddresses } from '../abis'; +import { StateSigner } from './signer'; /** * Channel identifier @@ -125,14 +126,9 @@ export interface NitroliteClientConfig { walletClient: WalletClient>; /** - * Optional: A separate viem WalletClient used *only* for signing off-chain state updates (`signMessage`). - * Provide this if you want to use a different key (e.g., a "hot" key from localStorage) - * for state signing than the one used for on-chain transactions. - * If omitted, `walletClient` will be used for state signing. - * @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. + * Implementation of the StateSigner interface used for signing protocol states. */ - stateWalletClient?: WalletClient>; + stateSigner: StateSigner; /** Contract addresses required by the SDK. */ addresses: ContractAddresses; diff --git a/sdk/src/rpc/api.ts b/sdk/src/rpc/api.ts index 775b04ccf..0ba215be4 100644 --- a/sdk/src/rpc/api.ts +++ b/sdk/src/rpc/api.ts @@ -28,6 +28,7 @@ import { GetLedgerTransactionsRequestParams, TransferRequestParams, } from './types/request'; +import { signRawECDSAMessage } from '../utils/sign'; /** * Creates the signed, stringified message body for an 'auth_request'. @@ -642,12 +643,8 @@ export function createEIP712AuthMessageSigner( export function createECDSAMessageSigner(privateKey: Hex): MessageSigner { return async (payload: RequestData | ResponsePayload): Promise => { try { - const messageBytes = keccak256( - stringToBytes(JSON.stringify(payload, (_, v) => (typeof v === 'bigint' ? v.toString() : v))), - ); - const flatSignature = await privateKeyToAccount(privateKey).sign({ hash: messageBytes }); - - return flatSignature as Hex; + const message = JSON.stringify(payload, (_, v) => (typeof v === 'bigint' ? v.toString() : v)); + return signRawECDSAMessage(message, privateKey); } catch (error) { console.error('ECDSA signing failed:', error); throw new Error(`ECDSA signing failed: ${error}`); diff --git a/sdk/src/utils/sign.ts b/sdk/src/utils/sign.ts new file mode 100644 index 000000000..87a10e6b4 --- /dev/null +++ b/sdk/src/utils/sign.ts @@ -0,0 +1,9 @@ +import { Hex, keccak256, stringToBytes } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +export const signRawECDSAMessage = async (message: string, privateKey: Hex): Promise => { + const messageBytes = keccak256(stringToBytes(message)); + const flatSignature = await privateKeyToAccount(privateKey).sign({ hash: messageBytes }); + + return flatSignature; +}; diff --git a/sdk/src/utils/state.ts b/sdk/src/utils/state.ts index d02fabc16..d5ffd254d 100644 --- a/sdk/src/utils/state.ts +++ b/sdk/src/utils/state.ts @@ -45,65 +45,16 @@ export function getStateHash(channelId: ChannelId, state: State): StateHash { } /** - * Function type for signing messages, compatible with Viem's WalletClient or Account. - * @dev Signing should NOT add an EIP-191 prefix to the message. - * @param args An object containing the message to sign in the `{ message: { raw: Hex } }` format. - * @returns A promise that resolves to the signature as a Hex string. - * @throws If the signing fails. - */ -type SignMessageFn = (args: { message: { raw: Hex } }) => Promise; - -// TODO: extract into an interface and provide on NitroliteClient creation -/** - * Create a raw ECDSA signature for a hash over a packed state using a Viem WalletClient or Account compatible signer. - * Uses the locally defined parseSignature function. - * @dev `signMessage` function should NOT add an EIP-191 prefix to the stateHash. See {@link SignMessageFn}. - * @param stateHash The hash of the state to sign. - * @param signer An object with a `signMessage` method compatible with Viem's interface (e.g., WalletClient, Account). - * @returns The signature over the state hash. - * @throws If the signer cannot sign messages or signing/parsing fails. - */ -export async function signState( - channelId: ChannelId, - state: State, - signMessage: SignMessageFn, -): Promise { - const stateHash = getStateHash(channelId, state); - try { - return await signMessage({ message: { raw: stateHash } }); - } catch (error) { - console.error('Error signing state hash:', error); - throw new Error(`Failed to sign state hash: ${error instanceof Error ? error.message : String(error)}`); - } -} - -/** - * Signs a challenge state for a channel. - * This function encodes the packed state and the challenge string, hashes it, and signs it. + * Calculate a challenge state for a channel. + * This function encodes the packed state and the challenge string and hashes it * @param channelId The ID of the channel. - * @param state The state to sign. - * @param signMessage The signing function compatible with Viem's WalletClient or Account. - * @returns The signature as a Hex string. - * @throws If signing fails. + * @param state The state to calculate with. + * @returns The challenge hash as a Hex string. */ -export async function signChallengeState( - channelId: ChannelId, - state: State, - signMessage: SignMessageFn, -): Promise { +export async function getChallengeHash(channelId: ChannelId, state: State): Promise { const packedState = getPackedState(channelId, state); - const encoded = encodePacked( - [ 'bytes', 'string' ], - [packedState, 'challenge'], - ); - const challengeHash = keccak256(encoded) as Hex; - - try { - return await signMessage({ message: { raw: challengeHash } }); - } catch (error) { - console.error('Error signing challenge state:', error); - throw new Error(`Failed to sign challenge state: ${error instanceof Error ? error.message : String(error)}`); - } + const encoded = encodePacked(['bytes', 'string'], [packedState, 'challenge']); + return keccak256(encoded); } // TODO: extract into an interface and provide on NitroliteClient creation diff --git a/sdk/test/unit/client/index.test.ts b/sdk/test/unit/client/index.test.ts index b89d5c413..e4535804d 100644 --- a/sdk/test/unit/client/index.test.ts +++ b/sdk/test/unit/client/index.test.ts @@ -31,6 +31,12 @@ describe('NitroliteClient', () => { let mockNitroService: any; let mockErc20Service: any; + const stateSigner = { + getAddress: jest.fn(async () => mockAccount.address), + signState: jest.fn(async (_1: Hex, _2: any) => mockSignature as Hex), + signRawMessage: jest.fn(async (_: Hex) => mockSignature as Hex), + } + beforeEach(() => { jest.restoreAllMocks(); client = new NitroliteClient({ @@ -39,7 +45,7 @@ describe('NitroliteClient', () => { addresses: mockAddresses, challengeDuration, chainId: chainId, - stateWalletClient: { ...mockWalletClient, signMessage: mockSignMessage }, + stateSigner, }); mockNitroService = { deposit: jest.fn(), diff --git a/sdk/test/unit/client/state.test.ts b/sdk/test/unit/client/state.test.ts index a4411998d..6ec6813c5 100644 --- a/sdk/test/unit/client/state.test.ts +++ b/sdk/test/unit/client/state.test.ts @@ -6,7 +6,7 @@ import { Hex } from 'viem'; import { _prepareAndSignInitialState, _prepareAndSignFinalState } from '../../../src/client/state'; import * as utils from '../../../src/utils'; import { Errors } from '../../../src/errors'; -import { StateIntent } from '../../../src/client/types'; +import { State, StateIntent } from '../../../src/client/types'; // Mock utils jest.mock('../../../src/utils', () => ({ @@ -19,19 +19,21 @@ jest.mock('../../../src/utils', () => ({ })); describe('_prepareAndSignInitialState', () => { - let deps: any; + let deps; const guestAddress = '0xGUEST' as Hex; const tokenAddress = '0xTOKEN' as Hex; const adjudicatorAddress = '0xADJ' as Hex; - const challengeDuration = 123; + const challengeDuration = BigInt(123); + const stateSigner = { + getAddress: jest.fn(async () => '0xOWNER' as Hex), + signState: jest.fn(async (_1: Hex, _2: State) => 'accSig'), + signRawMessage: jest.fn(async (_: Hex) => 'accSig'), + }; beforeEach(() => { deps = { account: { address: '0xOWNER' as Hex }, - stateWalletClient: { - account: { address: '0xOWNER' as Hex }, - signMessage: async (_: string) => 'walletSig', - }, + stateSigner, addresses: { guestAddress, adjudicator: adjudicatorAddress, @@ -73,13 +75,16 @@ describe('_prepareAndSignInitialState', () => { sigs: ['accSig'], }); // Signs the state - expect(utils.signState).toHaveBeenCalledWith('cid', { - data: 'customData', - intent: StateIntent.INITIALIZE, - allocations: expect.any(Array), - version: 0n, - sigs: [], - }, deps.stateWalletClient.signMessage); + expect(stateSigner.signState).toHaveBeenCalledWith( + 'cid', + { + data: 'customData', + intent: StateIntent.INITIALIZE, + allocations: expect.any(Array), + version: 0n, + sigs: [], + } + ); }); test('throws if no adjudicator', async () => { @@ -108,13 +113,15 @@ describe('_prepareAndSignFinalState', () => { const channelIdArg = 'cid' as Hex; const allocations = [{ destination: '0xA' as Hex, token: '0xT' as Hex, amount: 5n }]; const version = 7n; + const stateSigner = { + getAddress: jest.fn(async () => '0xOWNER' as Hex), + signState: jest.fn(async (_1: Hex, _2: State) => 'accSig'), + signRawMessage: jest.fn(async (_: Hex) => 'accSig'), + }; beforeEach(() => { deps = { - stateWalletClient: { - account: { address: '0xOWNER' as Hex }, - signMessage: async (_: string) => 'walletSig2', - }, + stateSigner, addresses: { /* not used */ }, @@ -147,13 +154,16 @@ describe('_prepareAndSignFinalState', () => { version, sigs: ['accSig', 'srvSig'], }); - expect(utils.signState).toHaveBeenCalledWith('cid', { - data: 'finalData', - intent: StateIntent.FINALIZE, - allocations, - version, - sigs: [], - }, deps.stateWalletClient.signMessage); + expect(stateSigner.signState).toHaveBeenCalledWith( + 'cid', + { + data: 'finalData', + intent: StateIntent.FINALIZE, + allocations, + version, + sigs: [], + } + ); }); test('throws if no stateData', async () => { diff --git a/sdk/test/unit/utils/state.test.ts b/sdk/test/unit/utils/state.test.ts index d66fc7795..9e05aa11f 100644 --- a/sdk/test/unit/utils/state.test.ts +++ b/sdk/test/unit/utils/state.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, jest } from '@jest/globals'; -import { getStateHash, signState, verifySignature } from '../../../src/utils/state'; +import { getStateHash, verifySignature } from '../../../src/utils/state'; import { type State, type Signature, type Allocation, StateIntent } from '../../../src/client/types'; import { Hex, Address, recoverMessageAddress, encodeAbiParameters, keccak256 } from 'viem'; @@ -53,40 +53,6 @@ describe('getStateHash', () => { }); }); -describe('signState', () => { - const channelId = '0xChannelId' as Hex; - const state: State = { - data: '0xdata' as Hex, - intent: StateIntent.INITIALIZE, - allocations: [ - { destination: '0xA' as Address, token: '0xT' as Address, amount: 10n }, - { destination: '0xB' as Address, token: '0xT' as Address, amount: 20n }, - ] as [Allocation, Allocation], - version: 0n, - sigs: [], - }; - const stateHash = getStateHash(channelId, state); - const expectedSignature = '0xrs1b' as Hex; - const signer = jest.fn(async ({ message }) => { - if (message.raw === stateHash) return expectedSignature; - throw new Error('sign fail'); - }); - - test('successfully signs and parses signature', async () => { - // @ts-ignore - const sig = await signState(channelId, state, signer); - expect(signer).toHaveBeenCalledWith({ message: { raw: stateHash } }); - expect(sig).toEqual(expectedSignature); - }); - - test('throws on signer error', async () => { - const badSigner = jest.fn(async () => { - throw new Error('bad'); - }); - await expect(signState(channelId, state, badSigner)).rejects.toThrow(/Failed to sign state hash: bad/); - }); -}); - describe('verifySignature', () => { const channelId = '0xChannelId' as Hex; const state: State = { From d4bf782f5c26430c26ed2970624700cb0e9aea9a Mon Sep 17 00:00:00 2001 From: MaxMoskalenko Date: Thu, 31 Jul 2025 12:46:17 +0300 Subject: [PATCH 2/4] fix: add stateSigner support for integration tests --- integration/common/identity.ts | 20 ++++---------------- integration/common/nitroliteClient.ts | 2 +- sdk/src/client/signer.ts | 6 +++--- sdk/src/client/state.ts | 4 ++-- sdk/src/rpc/api.ts | 7 +++++-- sdk/src/utils/sign.ts | 7 +++---- sdk/src/utils/state.ts | 2 +- sdk/test/unit/client/index.test.ts | 2 +- 8 files changed, 20 insertions(+), 30 deletions(-) diff --git a/integration/common/identity.ts b/integration/common/identity.ts index 28e75b8e4..4696f0330 100644 --- a/integration/common/identity.ts +++ b/integration/common/identity.ts @@ -2,10 +2,11 @@ import { Address, createWalletClient, Hex, http } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { chain } from './setup'; import { createECDSAMessageSigner } from '@erc7824/nitrolite'; +import { SessionKeyStateSigner } from '@erc7824/nitrolite/dist/client/signer'; export class Identity { public walletClient = null; - public stateWalletClient = null; + public stateSigner = null; public walletAddress: Address; public sessionAddress: Address; public messageSigner = null; @@ -20,21 +21,8 @@ export class Identity { transport: http(), }); - const sessionAccount = privateKeyToAccount(sessionPrivateKey); - this.sessionAddress = sessionAccount.address; - - this.stateWalletClient = { - ...this.walletClient, - account: { - address: this.sessionAddress, - }, - signMessage: async ({ message: { raw } }: { message: { raw: string } }) => { - const flatSignature = await sessionAccount.sign({ hash: raw as Hex }); - - return flatSignature as Hex; - }, - }; - + this.stateSigner = new SessionKeyStateSigner(sessionPrivateKey); this.messageSigner = createECDSAMessageSigner(sessionPrivateKey); + this.sessionAddress = this.stateSigner.getAddress(); } } diff --git a/integration/common/nitroliteClient.ts b/integration/common/nitroliteClient.ts index 6240e6b43..52b11494c 100644 --- a/integration/common/nitroliteClient.ts +++ b/integration/common/nitroliteClient.ts @@ -21,7 +21,7 @@ export class TestNitroliteClient extends NitroliteClient { // @ts-ignore publicClient, walletClient: identity.walletClient, - stateWalletClient: identity.stateWalletClient, + stateSigner: identity.stateSigner, account: identity.walletClient.account, chainId: chain.id, challengeDuration: BigInt(CONFIG.DEFAULT_CHALLENGE_TIMEOUT), // min diff --git a/sdk/src/client/signer.ts b/sdk/src/client/signer.ts index dfe3631ab..1bc5a3488 100644 --- a/sdk/src/client/signer.ts +++ b/sdk/src/client/signer.ts @@ -17,7 +17,7 @@ export interface StateSigner { * Get the address of the signer. * @returns The address of the signer as a Promise. */ - getAddress(): Promise
; + getAddress(): Address; /** * Sign a state for a given channel ID. * @param channelId The ID of the channel. @@ -47,7 +47,7 @@ export class WalletStateSigner implements StateSigner { this.walletClient = walletClient; } - async getAddress(): Promise
{ + getAddress(): Address { return this.walletClient.account.address; } @@ -76,7 +76,7 @@ export class SessionKeyStateSigner implements StateSigner { this.account = privateKeyToAccount(sessionKey); } - async getAddress(): Promise
{ + getAddress(): Address { return this.account.address; } diff --git a/sdk/src/client/state.ts b/sdk/src/client/state.ts index b842f285b..8c7893edb 100644 --- a/sdk/src/client/state.ts +++ b/sdk/src/client/state.ts @@ -38,7 +38,7 @@ export async function _prepareAndSignInitialState( const channelNonce = generateChannelNonce(deps.account.address); const participants: [Hex, Hex] = [deps.account.address, deps.addresses.guestAddress]; - const channelParticipants: [Hex, Hex] = [await deps.stateSigner.getAddress(), deps.addresses.guestAddress]; + const channelParticipants: [Hex, Hex] = [deps.stateSigner.getAddress(), deps.addresses.guestAddress]; const adjudicatorAddress = deps.addresses.adjudicator; if (!adjudicatorAddress) { throw new Errors.MissingParameterError( @@ -103,7 +103,7 @@ export async function _prepareAndSignChallengeState( challengerSig: Signature; }> { const { channelId, candidateState, proofStates = [] } = params; - const challengeHash = await getChallengeHash(channelId, candidateState); + const challengeHash = getChallengeHash(channelId, candidateState); const challengerSig = await deps.stateSigner.signRawMessage(challengeHash); return { channelId, candidateState, proofs: proofStates, challengerSig }; diff --git a/sdk/src/rpc/api.ts b/sdk/src/rpc/api.ts index 0ba215be4..d88c3d3ba 100644 --- a/sdk/src/rpc/api.ts +++ b/sdk/src/rpc/api.ts @@ -643,8 +643,11 @@ export function createEIP712AuthMessageSigner( export function createECDSAMessageSigner(privateKey: Hex): MessageSigner { return async (payload: RequestData | ResponsePayload): Promise => { try { - const message = JSON.stringify(payload, (_, v) => (typeof v === 'bigint' ? v.toString() : v)); - return signRawECDSAMessage(message, privateKey); + const messageBytes = keccak256( + stringToBytes(JSON.stringify(payload, (_, v) => (typeof v === 'bigint' ? v.toString() : v))), + ); + + return signRawECDSAMessage(messageBytes, privateKey); } catch (error) { console.error('ECDSA signing failed:', error); throw new Error(`ECDSA signing failed: ${error}`); diff --git a/sdk/src/utils/sign.ts b/sdk/src/utils/sign.ts index 87a10e6b4..46f67a871 100644 --- a/sdk/src/utils/sign.ts +++ b/sdk/src/utils/sign.ts @@ -1,9 +1,8 @@ -import { Hex, keccak256, stringToBytes } from 'viem'; +import { Hex } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -export const signRawECDSAMessage = async (message: string, privateKey: Hex): Promise => { - const messageBytes = keccak256(stringToBytes(message)); - const flatSignature = await privateKeyToAccount(privateKey).sign({ hash: messageBytes }); +export const signRawECDSAMessage = async (message: Hex, privateKey: Hex): Promise => { + const flatSignature = await privateKeyToAccount(privateKey).sign({ hash: message }); return flatSignature; }; diff --git a/sdk/src/utils/state.ts b/sdk/src/utils/state.ts index d5ffd254d..9752818f7 100644 --- a/sdk/src/utils/state.ts +++ b/sdk/src/utils/state.ts @@ -51,7 +51,7 @@ export function getStateHash(channelId: ChannelId, state: State): StateHash { * @param state The state to calculate with. * @returns The challenge hash as a Hex string. */ -export async function getChallengeHash(channelId: ChannelId, state: State): Promise { +export function getChallengeHash(channelId: ChannelId, state: State): Hex { const packedState = getPackedState(channelId, state); const encoded = encodePacked(['bytes', 'string'], [packedState, 'challenge']); return keccak256(encoded); diff --git a/sdk/test/unit/client/index.test.ts b/sdk/test/unit/client/index.test.ts index e4535804d..06d2d31e0 100644 --- a/sdk/test/unit/client/index.test.ts +++ b/sdk/test/unit/client/index.test.ts @@ -32,7 +32,7 @@ describe('NitroliteClient', () => { let mockErc20Service: any; const stateSigner = { - getAddress: jest.fn(async () => mockAccount.address), + getAddress: jest.fn(() => mockAccount.address), signState: jest.fn(async (_1: Hex, _2: any) => mockSignature as Hex), signRawMessage: jest.fn(async (_: Hex) => mockSignature as Hex), } From 6ba7ebbf097b2b3d22a7244dd7480a92e33449a9 Mon Sep 17 00:00:00 2001 From: MaxMoskalenko Date: Fri, 1 Aug 2025 10:42:27 +0300 Subject: [PATCH 3/4] fix: resolve comments --- sdk/src/client/signer.ts | 8 ++++---- sdk/src/client/state.ts | 6 +++--- sdk/src/utils/state.ts | 18 +++++++++++++++--- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/sdk/src/client/signer.ts b/sdk/src/client/signer.ts index 1bc5a3488..647c1d1f8 100644 --- a/sdk/src/client/signer.ts +++ b/sdk/src/client/signer.ts @@ -1,6 +1,6 @@ import { Account, Address, Chain, Hex, ParseAccount, toHex, Transport, WalletClient } from 'viem'; import { State } from './types'; -import { getStateHash } from '../utils'; +import { getPackedState, getStateHash } from '../utils'; import { signRawECDSAMessage } from '../utils/sign'; import { privateKeyToAccount } from 'viem/accounts'; @@ -15,7 +15,7 @@ import { privateKeyToAccount } from 'viem/accounts'; export interface StateSigner { /** * Get the address of the signer. - * @returns The address of the signer as a Promise. + * @returns The address of the signer. */ getAddress(): Address; /** @@ -52,9 +52,9 @@ export class WalletStateSigner implements StateSigner { } async signState(channelId: Hex, state: State): Promise { - const stateHash = getStateHash(channelId, state); + const packedState = getPackedState(channelId, state) - return this.walletClient.signMessage({ message: { raw: stateHash } }); + return this.walletClient.signMessage({ message: { raw: packedState } }); } async signRawMessage(message: Hex): Promise { diff --git a/sdk/src/client/state.ts b/sdk/src/client/state.ts index 8c7893edb..188b22a53 100644 --- a/sdk/src/client/state.ts +++ b/sdk/src/client/state.ts @@ -1,6 +1,6 @@ import { Address, Hex } from 'viem'; import * as Errors from '../errors'; -import { generateChannelNonce, getChallengeHash, getChannelId } from '../utils'; +import { generateChannelNonce, getChannelId, getPackedChallengeState } from '../utils'; import { PreparerDependencies } from './prepare'; import { ChallengeChannelParams, @@ -103,8 +103,8 @@ export async function _prepareAndSignChallengeState( challengerSig: Signature; }> { const { channelId, candidateState, proofStates = [] } = params; - const challengeHash = getChallengeHash(channelId, candidateState); - const challengerSig = await deps.stateSigner.signRawMessage(challengeHash); + const challengeMsg = getPackedChallengeState(channelId, candidateState); + const challengerSig = await deps.stateSigner.signRawMessage(challengeMsg); return { channelId, candidateState, proofs: proofStates, challengerSig }; } diff --git a/sdk/src/utils/state.ts b/sdk/src/utils/state.ts index 9752818f7..db586b21c 100644 --- a/sdk/src/utils/state.ts +++ b/sdk/src/utils/state.ts @@ -44,6 +44,20 @@ export function getStateHash(channelId: ChannelId, state: State): StateHash { return keccak256(getPackedState(channelId, state)) as StateHash; } +/** + * Get a packed challenge state for a channel. + * This function encodes the packed state and the challenge string.ß + * @param channelId The ID of the channel. + * @param state The state to calculate with. + * @returns The encoded and packed challenge state as a Hex string. + */ +export function getPackedChallengeState(channelId: ChannelId, state: State): Hex { + const packedState = getPackedState(channelId, state); + const encoded = encodePacked(['bytes', 'string'], [packedState, 'challenge']); + + return encoded; +} + /** * Calculate a challenge state for a channel. * This function encodes the packed state and the challenge string and hashes it @@ -52,9 +66,7 @@ export function getStateHash(channelId: ChannelId, state: State): StateHash { * @returns The challenge hash as a Hex string. */ export function getChallengeHash(channelId: ChannelId, state: State): Hex { - const packedState = getPackedState(channelId, state); - const encoded = encodePacked(['bytes', 'string'], [packedState, 'challenge']); - return keccak256(encoded); + return keccak256(getPackedChallengeState(channelId, state)); } // TODO: extract into an interface and provide on NitroliteClient creation From 0012a35e15b710da522e18d2f59c5043e27c73da Mon Sep 17 00:00:00 2001 From: MaxMoskalenko Date: Mon, 4 Aug 2025 12:14:23 +0300 Subject: [PATCH 4/4] fix: move keccak256 to signRawECDSAMessage --- sdk/src/client/signer.ts | 4 ++-- sdk/src/rpc/api.ts | 8 +++----- sdk/src/utils/sign.ts | 5 +++-- sdk/test/unit/client/state.test.ts | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/sdk/src/client/signer.ts b/sdk/src/client/signer.ts index 647c1d1f8..f3189c5b9 100644 --- a/sdk/src/client/signer.ts +++ b/sdk/src/client/signer.ts @@ -81,9 +81,9 @@ export class SessionKeyStateSigner implements StateSigner { } async signState(channelId: Hex, state: State): Promise { - const stateHash = getStateHash(channelId, state); + const packedState = getPackedState(channelId, state); - return signRawECDSAMessage(stateHash, this.sessionKey); + return signRawECDSAMessage(packedState, this.sessionKey); } async signRawMessage(message: Hex): Promise { diff --git a/sdk/src/rpc/api.ts b/sdk/src/rpc/api.ts index d88c3d3ba..3cde29a3b 100644 --- a/sdk/src/rpc/api.ts +++ b/sdk/src/rpc/api.ts @@ -1,4 +1,4 @@ -import { Address, Hex, keccak256, stringToBytes, WalletClient } from 'viem'; +import { Address, Hex, keccak256, stringToBytes, toHex, WalletClient } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { MessageSigner, @@ -643,11 +643,9 @@ export function createEIP712AuthMessageSigner( export function createECDSAMessageSigner(privateKey: Hex): MessageSigner { return async (payload: RequestData | ResponsePayload): Promise => { try { - const messageBytes = keccak256( - stringToBytes(JSON.stringify(payload, (_, v) => (typeof v === 'bigint' ? v.toString() : v))), - ); + const message = toHex(JSON.stringify(payload, (_, v) => (typeof v === 'bigint' ? v.toString() : v))); - return signRawECDSAMessage(messageBytes, privateKey); + return signRawECDSAMessage(message, privateKey); } catch (error) { console.error('ECDSA signing failed:', error); throw new Error(`ECDSA signing failed: ${error}`); diff --git a/sdk/src/utils/sign.ts b/sdk/src/utils/sign.ts index 46f67a871..4065dedd7 100644 --- a/sdk/src/utils/sign.ts +++ b/sdk/src/utils/sign.ts @@ -1,8 +1,9 @@ -import { Hex } from 'viem'; +import { Hex, keccak256 } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; export const signRawECDSAMessage = async (message: Hex, privateKey: Hex): Promise => { - const flatSignature = await privateKeyToAccount(privateKey).sign({ hash: message }); + const hash = keccak256(message); + const flatSignature = await privateKeyToAccount(privateKey).sign({ hash }); return flatSignature; }; diff --git a/sdk/test/unit/client/state.test.ts b/sdk/test/unit/client/state.test.ts index 6ec6813c5..02d22cc94 100644 --- a/sdk/test/unit/client/state.test.ts +++ b/sdk/test/unit/client/state.test.ts @@ -25,7 +25,7 @@ describe('_prepareAndSignInitialState', () => { const adjudicatorAddress = '0xADJ' as Hex; const challengeDuration = BigInt(123); const stateSigner = { - getAddress: jest.fn(async () => '0xOWNER' as Hex), + getAddress: jest.fn(() => '0xOWNER' as Hex), signState: jest.fn(async (_1: Hex, _2: State) => 'accSig'), signRawMessage: jest.fn(async (_: Hex) => 'accSig'), };