From b87d37e26a81c2f2babc9044544ff1fe7dc30f5d Mon Sep 17 00:00:00 2001 From: yasha-meursault Date: Tue, 10 Mar 2026 18:03:29 +0400 Subject: [PATCH] initial unit tests for blockchains packages --- .../aztec/src/__tests__/client.test.ts | 445 +++++++++++++ .../aztec/src/__tests__/helpers.ts | 30 + .../aztec/src/__tests__/utils.test.ts | 44 ++ .../aztec/src/__tests__/wallet-sign.test.ts | 51 ++ .../evm/src/__tests__/client.test.ts | 621 ++++++++++++++++++ .../blockchains/evm/src/__tests__/helpers.ts | 57 ++ .../blockchains/evm/src/__tests__/rpc.test.ts | 128 ++++ .../evm/src/__tests__/wallet-sign.test.ts | 100 +++ .../solana/src/__tests__/client.test.ts | 338 +++++++++- .../solana/src/__tests__/wallet-sign.test.ts | 55 ++ .../starknet/src/__tests__/client.test.ts | 467 +++++++++++++ .../starknet/src/__tests__/helpers.ts | 32 + .../src/__tests__/wallet-sign.test.ts | 60 ++ 13 files changed, 2427 insertions(+), 1 deletion(-) create mode 100644 packages/blockchains/aztec/src/__tests__/client.test.ts create mode 100644 packages/blockchains/aztec/src/__tests__/helpers.ts create mode 100644 packages/blockchains/aztec/src/__tests__/utils.test.ts create mode 100644 packages/blockchains/aztec/src/__tests__/wallet-sign.test.ts create mode 100644 packages/blockchains/evm/src/__tests__/client.test.ts create mode 100644 packages/blockchains/evm/src/__tests__/helpers.ts create mode 100644 packages/blockchains/evm/src/__tests__/rpc.test.ts create mode 100644 packages/blockchains/evm/src/__tests__/wallet-sign.test.ts create mode 100644 packages/blockchains/solana/src/__tests__/wallet-sign.test.ts create mode 100644 packages/blockchains/starknet/src/__tests__/client.test.ts create mode 100644 packages/blockchains/starknet/src/__tests__/helpers.ts create mode 100644 packages/blockchains/starknet/src/__tests__/wallet-sign.test.ts diff --git a/packages/blockchains/aztec/src/__tests__/client.test.ts b/packages/blockchains/aztec/src/__tests__/client.test.ts new file mode 100644 index 0000000..b5759df --- /dev/null +++ b/packages/blockchains/aztec/src/__tests__/client.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + SAMPLE_ADDRESS, + SAMPLE_LP_ADDRESS, + SAMPLE_CONTRACT, + SAMPLE_TOKEN, + SAMPLE_HASHLOCK, + SAMPLE_TX_HASH, + createMockSigner, + createMockApiClient, +} from './helpers.js' + +// vi.hoisted runs before vi.mock factories, making these available +const { mockTrainMethods, mockSendResult } = vi.hoisted(() => ({ + mockTrainMethods: { + user_lock: { __mock: true } as any, + refund_user: { __mock: true } as any, + redeem_solver: { __mock: true } as any, + get_user_lock: { __mock: true } as any, + get_solver_lock: { __mock: true } as any, + get_solver_lock_count: { __mock: true } as any, + }, + mockSendResult: { + txHash: { toString: () => '0x' + 'bb'.repeat(32) }, + hasExecutionReverted: { __mock: true } as any, + error: null as any, + }, +})) + +vi.mock('@aztec/aztec.js/abi', () => ({ + EventSelector: { fromField: vi.fn().mockReturnValue({ toString: () => 'selector' }) }, + decodeFromAbi: vi.fn().mockReturnValue({}), +})) + +vi.mock('@aztec/aztec.js/addresses', () => ({ + AztecAddress: { + fromString: vi.fn((s: string) => ({ toString: () => s })), + }, +})) + +vi.mock('@aztec/aztec.js/authorization', () => ({ + SetPublicAuthwitContractInteraction: { + create: vi.fn().mockResolvedValue({ request: vi.fn() }), + }, +})) + +vi.mock('@aztec/aztec.js/contracts', () => ({ + BatchCall: class { + constructor() {} + send = vi.fn().mockResolvedValue(mockSendResult) + }, + getContractInstanceFromInstantiationParams: vi.fn().mockResolvedValue({ + address: { toString: () => '0xfpc' }, + }), +})) + +vi.mock('@aztec/aztec.js/fee', () => ({ + SponsoredFeePaymentMethod: class { + constructor() {} + }, +})) + +vi.mock('@aztec/aztec.js/fields', () => { + function Fr() {} + Fr.random = vi.fn().mockReturnValue({ toBuffer: () => Buffer.alloc(32) }) + Fr.fromBuffer = vi.fn().mockReturnValue({}) + return { Fr } +}) + +vi.mock('@aztec/aztec.js/node', () => ({ + createAztecNodeClient: vi.fn().mockReturnValue({ + getContract: vi.fn().mockResolvedValue({ artifact: {} }), + getPublicLogs: vi.fn().mockResolvedValue({ logs: [] }), + }), +})) + +vi.mock('@aztec/aztec.js/tx', () => ({ + TxHash: { fromString: vi.fn().mockReturnValue({}) }, +})) + +vi.mock('@aztec/aztec.js/wallet', () => ({})) + +vi.mock('@aztec/noir-contracts.js/SponsoredFPC', () => ({ + SponsoredFPCContract: { artifact: {} }, +})) + +vi.mock('../artifacts/Train', () => ({ + TrainContract: { + at: vi.fn().mockReturnValue({ methods: mockTrainMethods }), + artifact: {}, + events: { + UserLocked: { + eventSelector: { toString: () => 'selector' }, + abiType: {}, + }, + }, + }, +})) + +vi.mock('../artifacts/Token', () => ({ + TokenContract: { + at: vi.fn().mockReturnValue({ + methods: { + transfer_public_to_public: vi.fn().mockReturnValue({}), + }, + }), + artifact: {}, + }, +})) + +import { AztecHTLCClient } from '../client.js' + +function createClient(opts?: { withSigner?: boolean }) { + const signer = opts?.withSigner !== false ? createMockSigner() : undefined + return { + client: new AztecHTLCClient({ + rpcUrl: 'https://aztec.example.com', + apiClient: createMockApiClient(), + signer: signer as any, + }), + signer, + } +} + +function initMocks() { + // Initialize mockTrainMethods with vi.fn() (can't be done in vi.hoisted) + mockTrainMethods.user_lock = vi.fn().mockReturnValue({ send: vi.fn() }) + mockTrainMethods.refund_user = vi.fn().mockReturnValue({ send: vi.fn().mockResolvedValue(mockSendResult) }) + mockTrainMethods.redeem_solver = vi.fn().mockReturnValue({ send: vi.fn().mockResolvedValue(mockSendResult) }) + mockTrainMethods.get_user_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ status: 0 }), + }) + mockTrainMethods.get_solver_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({}), + }) + mockTrainMethods.get_solver_lock_count = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue(0), + }) + mockSendResult.hasExecutionReverted = vi.fn().mockReturnValue(false) + mockSendResult.error = null +} + +describe('AztecHTLCClient', () => { + beforeEach(() => { + vi.clearAllMocks() + initMocks() + }) + + describe('userLock', () => { + const params = { + destinationChain: 'eip155:1', + sourceChain: 'aztec:devnet', + amount: '1.0', + destinationAmount: '1000000000000000000', + decimals: 18, + destinationAsset: '0xdsttoken', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'TOKEN', decimals: 18 }, + destLpAddress: SAMPLE_LP_ADDRESS, + srcLpAddress: SAMPLE_LP_ADDRESS, + atomicContract: SAMPLE_CONTRACT, + sourceAddress: SAMPLE_ADDRESS, + destinationAddress: '0xdestaddr', + tokenContractAddress: SAMPLE_TOKEN, + hashlock: SAMPLE_HASHLOCK, + nonce: 1700000000000, + quoteExpiry: 1700001000, + timelockDelta: 40, + } as any + + it('returns hash, hashlock, and nonce on success', async () => { + const { client } = createClient() + const result = await client.userLock(params) + + expect(result.hash).toBe(SAMPLE_TX_HASH) + expect(result.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result.nonce).toBe(1700000000000) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + await expect(client.userLock(params)).rejects.toThrow('Signer required') + }) + + it('throws when batch transaction reverts', async () => { + mockSendResult.hasExecutionReverted = vi.fn().mockReturnValue(true) + mockSendResult.error = 'out of gas' as any + + const { client } = createClient() + await expect(client.userLock(params)).rejects.toThrow('user_lock reverted') + }) + }) + + describe('refund', () => { + const params = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + type: 'erc20' as const, + chainId: 'aztec:devnet', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'TOKEN', decimals: 18 }, + } + + it('returns transaction hash on success', async () => { + const { client } = createClient() + const result = await client.refund(params) + expect(result).toBe(SAMPLE_TX_HASH) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + await expect(client.refund(params)).rejects.toThrow('Signer required') + }) + + it('throws when execution reverts', async () => { + mockSendResult.hasExecutionReverted = vi.fn().mockReturnValue(true) + mockSendResult.error = 'timelock not expired' as any + + const { client } = createClient() + await expect(client.refund(params)).rejects.toThrow('refund_user reverted') + }) + }) + + describe('redeemSolver', () => { + const params = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + secret: 12345n, + type: 'erc20' as const, + chainId: 'aztec:devnet', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'TOKEN', decimals: 18 }, + destLpAddress: SAMPLE_LP_ADDRESS, + } + + it('returns transaction hash on success', async () => { + const { client } = createClient() + const result = await client.redeemSolver(params) + expect(result).toBe(SAMPLE_TX_HASH) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + await expect(client.redeemSolver(params)).rejects.toThrow('Signer required') + }) + + it('throws when execution reverts', async () => { + mockSendResult.hasExecutionReverted = vi.fn().mockReturnValue(true) + const { client } = createClient() + await expect(client.redeemSolver(params)).rejects.toThrow('redeem_solver reverted') + }) + }) + + describe('getUserLockDetails', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: 'aztec:devnet', + } + + it('returns null when status is 0', async () => { + mockTrainMethods.get_user_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ status: 0 }), + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result).toBeNull() + }) + + it('returns mapped details when lock exists', async () => { + mockTrainMethods.get_user_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ + status: 1, + amount: 1000000000000000000n, + sender: { toString: () => SAMPLE_ADDRESS }, + recipient: { toString: () => SAMPLE_LP_ADDRESS }, + token: { toString: () => SAMPLE_TOKEN }, + timelock: 1700001000n, + secret: [0, 0, 0], + }), + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result).not.toBeNull() + expect(result!.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result!.status).toBe(1) + expect(result!.timelock).toBe(1700001000) + }) + + it('returns undefined secret when bytes are all zero', async () => { + mockTrainMethods.get_user_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ + status: 1, + amount: 1000000000000000000n, + sender: { toString: () => SAMPLE_ADDRESS }, + recipient: { toString: () => SAMPLE_LP_ADDRESS }, + token: { toString: () => SAMPLE_TOKEN }, + timelock: 1700001000n, + secret: [0, 0, 0], + }), + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.secret).toBeUndefined() + }) + + it('returns secret bigint when bytes are non-zero', async () => { + mockTrainMethods.get_user_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ + status: 1, + amount: 1000000000000000000n, + sender: { toString: () => SAMPLE_ADDRESS }, + recipient: { toString: () => SAMPLE_LP_ADDRESS }, + token: { toString: () => SAMPLE_TOKEN }, + timelock: 1700001000n, + secret: [0, 0, 1, 0], + }), + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.secret).toBeDefined() + expect(typeof result!.secret).toBe('bigint') + }) + }) + + describe('_getSolverLockDetails', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: 'aztec:devnet', + } + const nodeUrl = 'https://aztec-node.example.com' + + it('returns null when count is 0', async () => { + mockTrainMethods.get_solver_lock_count = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue(0), + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + expect(result).toBeNull() + }) + + it('returns first valid solver lock', async () => { + mockTrainMethods.get_solver_lock_count = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue(1), + }) + mockTrainMethods.get_solver_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ + status: 1, + amount: 500000000000000000n, + sender: { toString: () => SAMPLE_LP_ADDRESS }, + recipient: { toString: () => SAMPLE_ADDRESS }, + token: { toString: () => SAMPLE_TOKEN }, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: { toString: () => '' }, + reward_token: { toString: () => '' }, + secret: [0, 0, 0], + }), + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result!.status).toBe(1) + expect(result!.index).toBe(1) + }) + + it('skips locks with status 0', async () => { + mockTrainMethods.get_solver_lock_count = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue(2), + }) + const getSolverLock = vi.fn() + .mockReturnValueOnce({ + simulate: vi.fn().mockResolvedValue({ status: 0 }), + }) + .mockReturnValueOnce({ + simulate: vi.fn().mockResolvedValue({ + status: 1, + amount: 500000000000000000n, + sender: { toString: () => SAMPLE_LP_ADDRESS }, + recipient: { toString: () => SAMPLE_ADDRESS }, + token: { toString: () => SAMPLE_TOKEN }, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: { toString: () => '' }, + reward_token: { toString: () => '' }, + secret: [0, 0, 0], + }), + }) + mockTrainMethods.get_solver_lock = getSolverLock + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.index).toBe(2) + }) + + it('filters by solverAddress', async () => { + mockTrainMethods.get_solver_lock_count = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue(1), + }) + mockTrainMethods.get_solver_lock = vi.fn().mockReturnValue({ + simulate: vi.fn().mockResolvedValue({ + status: 1, + amount: 500000000000000000n, + sender: { toString: () => SAMPLE_LP_ADDRESS }, + recipient: { toString: () => SAMPLE_ADDRESS }, + token: { toString: () => SAMPLE_TOKEN }, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: { toString: () => '' }, + reward_token: { toString: () => '' }, + secret: [0, 0, 0], + }), + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails( + { ...lockParams, solverAddress: '0xnonmatching' }, + nodeUrl, + ) + + expect(result).toBeNull() + }) + }) + + describe('recoverSwap', () => { + it('throws not supported', async () => { + const { client } = createClient() + await expect(client.recoverSwap('0xabc')).rejects.toThrow( + 'recoverSwap is not supported for Aztec' + ) + }) + }) +}) diff --git a/packages/blockchains/aztec/src/__tests__/helpers.ts b/packages/blockchains/aztec/src/__tests__/helpers.ts new file mode 100644 index 0000000..f1938f4 --- /dev/null +++ b/packages/blockchains/aztec/src/__tests__/helpers.ts @@ -0,0 +1,30 @@ +import { vi } from 'vitest' +import type { TrainApiClient } from '@train-protocol/sdk' + +export const SAMPLE_ADDRESS = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' +export const SAMPLE_LP_ADDRESS = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd' +export const SAMPLE_CONTRACT = '0x9999999999999999999999999999999999999999999999999999999999999999' +export const SAMPLE_TOKEN = '0x5555555555555555555555555555555555555555555555555555555555555555' +export const SAMPLE_HASHLOCK = '0x' + 'aa'.repeat(32) +export const SAMPLE_TX_HASH = '0x' + 'bb'.repeat(32) + +export function createMockWallet(): Record> { + return { + getAccounts: vi.fn().mockResolvedValue([{ item: SAMPLE_ADDRESS }]), + registerContract: vi.fn().mockResolvedValue(undefined), + registerSender: vi.fn().mockResolvedValue(undefined), + } +} + +export function createMockSigner(): { wallet: Record>; address: string } { + return { + wallet: createMockWallet(), + address: SAMPLE_ADDRESS, + } +} + +export function createMockApiClient(): TrainApiClient { + return { + revealSecret: vi.fn().mockResolvedValue(undefined), + } as unknown as TrainApiClient +} diff --git a/packages/blockchains/aztec/src/__tests__/utils.test.ts b/packages/blockchains/aztec/src/__tests__/utils.test.ts new file mode 100644 index 0000000..4ff20e1 --- /dev/null +++ b/packages/blockchains/aztec/src/__tests__/utils.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { stringToBytes } from '../utils.js' + +describe('stringToBytes', () => { + it('converts ASCII string to byte array of specified length', () => { + const result = stringToBytes('abc', 5) + expect(result).toHaveLength(5) + // 'abc' is 3 bytes, left-padded with 2 spaces + expect(result[0]).toBe(32) // space + expect(result[1]).toBe(32) // space + expect(result[2]).toBe(97) // 'a' + expect(result[3]).toBe(98) // 'b' + expect(result[4]).toBe(99) // 'c' + }) + + it('left-pads with spaces when string is shorter', () => { + const result = stringToBytes('x', 4) + expect(result).toHaveLength(4) + // 3 spaces + 'x' + expect(result[0]).toBe(32) + expect(result[1]).toBe(32) + expect(result[2]).toBe(32) + expect(result[3]).toBe(120) // 'x' + }) + + it('handles exact length string', () => { + const result = stringToBytes('hi', 2) + expect(result).toHaveLength(2) + expect(result[0]).toBe(104) // 'h' + expect(result[1]).toBe(105) // 'i' + }) + + it('truncates when encoded bytes exceed length', () => { + const result = stringToBytes('hello world', 5) + expect(result).toHaveLength(5) + }) + + it('handles empty string', () => { + const result = stringToBytes('', 3) + expect(result).toHaveLength(3) + // All spaces + expect(result.every(b => b === 32)).toBe(true) + }) +}) diff --git a/packages/blockchains/aztec/src/__tests__/wallet-sign.test.ts b/packages/blockchains/aztec/src/__tests__/wallet-sign.test.ts new file mode 100644 index 0000000..9d7bdb0 --- /dev/null +++ b/packages/blockchains/aztec/src/__tests__/wallet-sign.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@train-protocol/sdk', () => ({ + deriveKeyMaterial: vi.fn().mockReturnValue(new Uint8Array(32).fill(0xab)), + IDENTITY_SALT: 'train-identity-salt', +})) + +vi.mock('@aztec/aztec.js/fields', () => ({ + Fr: { + fromBuffer: vi.fn().mockReturnValue({ toBuffer: () => Buffer.alloc(32) }), + }, +})) + +vi.mock('@aztec/aztec.js/addresses', () => ({ + AztecAddress: { + fromString: vi.fn((s: string) => ({ toString: () => s })), + }, +})) + +import { deriveKeyFromAztecWallet, type AztecWalletLike } from '../login/wallet-sign.js' +import { deriveKeyMaterial } from '@train-protocol/sdk' + +function createMockWallet(overrides?: Partial): AztecWalletLike { + return { + createAuthWit: vi.fn().mockResolvedValue({ + toBuffer: () => Buffer.from('mockwitness'), + }), + ...overrides, + } +} + +describe('deriveKeyFromAztecWallet', () => { + beforeEach(() => { + vi.resetAllMocks() + ;(deriveKeyMaterial as any).mockReturnValue(new Uint8Array(32).fill(0xab)) + }) + + it('returns Buffer from deriveKeyMaterial', async () => { + const wallet = createMockWallet() + const result = await deriveKeyFromAztecWallet(wallet, '0xaddr') + + expect(Buffer.isBuffer(result)).toBe(true) + expect(deriveKeyMaterial).toHaveBeenCalled() + }) + + it('throws when wallet is falsy', async () => { + await expect( + deriveKeyFromAztecWallet(null as any, '0xaddr') + ).rejects.toThrow('Aztec wallet not connected') + }) +}) diff --git a/packages/blockchains/evm/src/__tests__/client.test.ts b/packages/blockchains/evm/src/__tests__/client.test.ts new file mode 100644 index 0000000..1c225c6 --- /dev/null +++ b/packages/blockchains/evm/src/__tests__/client.test.ts @@ -0,0 +1,621 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EvmHTLCClient } from '../client.js' +import { + ZERO_ADDRESS, + SAMPLE_ADDRESS, + SAMPLE_LP_ADDRESS, + SAMPLE_CONTRACT, + SAMPLE_TOKEN, + SAMPLE_HASHLOCK, + SAMPLE_TX_HASH, + createMockSigner, + createMockApiClient, + createMockRpc, +} from './helpers.js' + +// Mock ox module +vi.mock('ox', () => ({ + AbiFunction: { + encodeData: vi.fn().mockReturnValue('0xencoded'), + decodeResult: vi.fn().mockReturnValue({}), + fromAbi: vi.fn().mockReturnValue({}), + from: vi.fn().mockReturnValue({}), + }, + AbiEvent: { + decode: vi.fn().mockReturnValue({}), + fromAbi: vi.fn().mockReturnValue({}), + }, +})) + +// Mock abi.js (loaded at module level, must be mocked before import) +vi.mock('../abi.js', () => ({ + htlcFunctions: { + userLock: { name: 'userLock' }, + refundUser: { name: 'refundUser' }, + redeemSolver: { name: 'redeemSolver' }, + getUserLock: { name: 'getUserLock' }, + getSolverLock: { name: 'getSolverLock' }, + getSolverLockCount: { name: 'getSolverLockCount' }, + }, + htlcEvents: { + UserLocked: { name: 'UserLocked' }, + }, + erc20Functions: { + allowance: { name: 'allowance' }, + approve: { name: 'approve' }, + }, +})) + +// We need to mock JsonRpcClient constructor +const mockRpc = createMockRpc() +vi.mock('../rpc.js', () => { + return { + JsonRpcClient: class { + constructor() { + return mockRpc + } + }, + JsonRpcError: class extends Error { + code: number + data?: unknown + constructor(message: string, code: number, data?: unknown) { + super(message) + this.code = code + this.data = data + } + }, + } +}) + +// Import mocked modules for assertion access +import { AbiFunction, AbiEvent } from 'ox' + +function createClient(opts?: { withSigner?: boolean }) { + const signer = opts?.withSigner !== false ? createMockSigner() : undefined + return { + client: new EvmHTLCClient({ + rpcUrl: 'https://rpc.example.com', + apiClient: createMockApiClient(), + signer, + }), + signer, + } +} + +function createUserLockParams(overrides?: Record) { + return { + destinationChain: 'eip155:1', + sourceChain: 'eip155:11155111', + amount: '1.0', + destinationAmount: '1000000000000000000', + decimals: 18, + destinationAsset: '0xdsttoken', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'USDC', decimals: 18 }, + destLpAddress: SAMPLE_LP_ADDRESS, + srcLpAddress: SAMPLE_LP_ADDRESS, + atomicContract: SAMPLE_CONTRACT, + sourceAddress: SAMPLE_ADDRESS, + destinationAddress: '0xdestaddr', + hashlock: SAMPLE_HASHLOCK, + nonce: 1700000000000, + quoteExpiry: 1700001000, + timelockDelta: 150, + ...overrides, + } as any +} + +describe('EvmHTLCClient', () => { + beforeEach(() => { + vi.resetAllMocks() + // Re-set defaults after reset (resetAllMocks clears all implementations) + mockRpc.ethCall.mockResolvedValue('0x') + mockRpc.getTransactionReceipt.mockResolvedValue(null) + mockRpc.getTransaction.mockResolvedValue(null) + mockRpc.getBlockByNumber.mockResolvedValue(null) + ; (AbiFunction.encodeData as any).mockReturnValue('0xencoded') + ; (AbiFunction.decodeResult as any).mockReturnValue({}) + ; (AbiEvent.decode as any).mockReturnValue({}) + }) + + describe('userLock', () => { + it('returns hash, hashlock, and nonce on success', async () => { + const { client } = createClient() + // allowance check returns sufficient allowance + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(10n ** 30n) + + const result = await client.userLock(createUserLockParams()) + + expect(result.hash).toBe(SAMPLE_TX_HASH) + expect(result.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result.nonce).toBe(1700000000000) + }) + + it('simulates via eth_call before sending transaction', async () => { + const { client, signer } = createClient() + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(10n ** 30n) + + await client.userLock(createUserLockParams()) + + // eth_call simulation should happen before sendTransaction + expect(mockRpc.ethCall).toHaveBeenCalled() + expect(signer!.sendTransaction).toHaveBeenCalled() + }) + + it('sends value for native token (no contractAddress)', async () => { + const { client, signer } = createClient() + + const params = createUserLockParams({ + sourceAsset: { contractAddress: undefined, symbol: 'ETH', decimals: 18, name: 'ETH' }, + }) + + await client.userLock(params) + + expect(signer!.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ value: expect.anything() }) + ) + }) + + it('skips ERC20 allowance check for native tokens', async () => { + const { client } = createClient() + + const params = createUserLockParams({ + sourceAsset: { contractAddress: undefined, symbol: 'ETH', decimals: 18, name: 'ETH' }, + }) + + await client.userLock(params) + + // Should not call decodeResult for allowance (only for simulation) + // The key indicator is that sendTransaction value should be set + expect((AbiFunction.encodeData as any).mock.calls[0][0].name).toBe('userLock') + }) + + it('checks and approves ERC20 when allowance is insufficient', async () => { + const { client, signer } = createClient() + // First decodeResult: allowance = 0 (insufficient) + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(0n) + // waitForReceipt (now private) polls getTransactionReceipt + mockRpc.getTransactionReceipt.mockResolvedValue({ status: '0x1' }) + + await client.userLock(createUserLockParams()) + + // sendTransaction called twice: once for approve, once for userLock + expect(signer!.sendTransaction).toHaveBeenCalledTimes(2) + }) + + it('skips approve when ERC20 allowance is sufficient', async () => { + const { client, signer } = createClient() + // allowance >= required + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(10n ** 30n) + + await client.userLock(createUserLockParams()) + + // sendTransaction called only once (for userLock, not approve) + expect(signer!.sendTransaction).toHaveBeenCalledTimes(1) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + + await expect(client.userLock(createUserLockParams())).rejects.toThrow('Signer required') + }) + + it('propagates eth_call simulation errors', async () => { + const { client } = createClient() + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(10n ** 30n) + mockRpc.ethCall.mockRejectedValueOnce(new Error('execution reverted')) + + await expect(client.userLock(createUserLockParams())).rejects.toThrow('execution reverted') + }) + }) + + describe('refund', () => { + it('encodes and sends refund transaction', async () => { + const { client, signer } = createClient() + + const result = await client.refund({ + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + type: 'erc20', + chainId: '11155111', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'USDC', decimals: 18 }, + }) + + expect(result).toBe(SAMPLE_TX_HASH) + expect(mockRpc.ethCall).toHaveBeenCalledWith( + SAMPLE_CONTRACT, + '0xencoded', + SAMPLE_ADDRESS, + ) + expect(signer!.sendTransaction).toHaveBeenCalledWith({ + to: SAMPLE_CONTRACT, + data: '0xencoded', + }) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + + await expect( + client.refund({ + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + type: 'erc20', + chainId: '11155111', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'USDC', decimals: 18 }, + }) + ).rejects.toThrow('Signer required') + }) + }) + + describe('redeemSolver', () => { + const redeemParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + secret: '12345' as string | bigint, + type: 'erc20' as const, + chainId: '11155111', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'USDC', decimals: 18 }, + destLpAddress: SAMPLE_LP_ADDRESS, + } + + it('encodes and sends redeem transaction', async () => { + const { client } = createClient() + + const result = await client.redeemSolver(redeemParams) + + expect(result).toBe(SAMPLE_TX_HASH) + expect(AbiFunction.encodeData).toHaveBeenCalledWith( + expect.objectContaining({ name: 'redeemSolver' }), + expect.arrayContaining([expect.anything(), 1n, 12345n]) + ) + }) + + it('uses destinationAddress as caller when provided', async () => { + const { client } = createClient() + const destAddr = '0xdestination' + + await client.redeemSolver({ ...redeemParams, destinationAddress: destAddr }) + + expect(mockRpc.ethCall).toHaveBeenCalledWith( + SAMPLE_CONTRACT, + '0xencoded', + destAddr, + ) + }) + + it('falls back to signer address when no destinationAddress', async () => { + const { client } = createClient() + + await client.redeemSolver(redeemParams) + + expect(mockRpc.ethCall).toHaveBeenCalledWith( + SAMPLE_CONTRACT, + '0xencoded', + SAMPLE_ADDRESS, + ) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + + await expect(client.redeemSolver(redeemParams)).rejects.toThrow('Signer required') + }) + }) + + describe('getUserLockDetails', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: '11155111', + } + + it('returns mapped lock details when lock exists', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_ADDRESS, + recipient: SAMPLE_LP_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + status: 1n, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result).toMatchObject({ + hashlock: SAMPLE_HASHLOCK, + sender: SAMPLE_ADDRESS, + recipient: SAMPLE_LP_ADDRESS, + token: SAMPLE_TOKEN, + timelock: 1700001000, + status: 1, + }) + expect(result!.secret).toBeUndefined() // secret is 0n + }) + + it('returns undefined fields when sender is zero address', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: ZERO_ADDRESS, + recipient: ZERO_ADDRESS, + token: ZERO_ADDRESS, + amount: 0n, + secret: 0n, + timelock: 0n, + status: 0n, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result!.hashlock).toBeUndefined() + expect(result!.sender).toBeUndefined() + expect(result!.status).toBeUndefined() + }) + + it('fetches userData from receipt logs when txId provided', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_ADDRESS, + recipient: SAMPLE_LP_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + status: 1n, + }) + + const receipt = { + logs: [{ data: '0x', topics: ['0xtopic'], address: SAMPLE_CONTRACT, logIndex: '0x0', blockNumber: '0x10', transactionHash: SAMPLE_TX_HASH }], + blockNumber: '0x10', + transactionHash: SAMPLE_TX_HASH, + status: '0x1', + } + mockRpc.getTransactionReceipt.mockResolvedValueOnce(receipt) + mockRpc.getBlockByNumber.mockResolvedValueOnce({ number: '0x10', timestamp: '0x65a00000' }) + + // AbiEvent.decode returns event with userData + ; (AbiEvent.decode as any).mockReturnValueOnce({ + hashlock: SAMPLE_HASHLOCK, + userData: '0x' + BigInt(1700000000000).toString(16), + }) + + const { client } = createClient() + const result = await client.getUserLockDetails({ ...lockParams, txId: SAMPLE_TX_HASH }) + + expect(result!.userData).toBe('1700000000000') + expect(result!.blockTimestamp).toBeDefined() + }) + + it('handles missing txId gracefully', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_ADDRESS, + recipient: SAMPLE_LP_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + status: 1n, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result!.userData).toBeUndefined() + expect(result!.blockTimestamp).toBeUndefined() + expect(mockRpc.getTransactionReceipt).not.toHaveBeenCalled() + }) + + it('returns secret as bigint when non-zero', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_ADDRESS, + recipient: SAMPLE_LP_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 42n, + timelock: 1700001000n, + status: 1n, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result!.secret).toBe(42n) + }) + }) + + describe('_getSolverLockDetails', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: '11155111', + } + const nodeUrl = 'https://node.example.com' + + it('returns null when solver lock count is 0', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(0) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).toBeNull() + }) + + it('returns first valid solver lock', async () => { + // count = 1 + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(1) + // solver lock result + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_LP_ADDRESS, + recipient: SAMPLE_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + rewardTimelock: 0n, + rewardRecipient: ZERO_ADDRESS, + rewardToken: ZERO_ADDRESS, + status: 1n, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).toMatchObject({ + hashlock: SAMPLE_HASHLOCK, + sender: SAMPLE_LP_ADDRESS, + status: 1, + }) + }) + + it('skips locks with zero address sender', async () => { + // count = 2 + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(2) + // first lock: zero sender + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: ZERO_ADDRESS, + recipient: ZERO_ADDRESS, + token: ZERO_ADDRESS, + amount: 0n, + secret: 0n, + timelock: 0n, + reward: 0n, + rewardTimelock: 0n, + rewardRecipient: ZERO_ADDRESS, + rewardToken: ZERO_ADDRESS, + status: 0n, + }) + // second lock: valid + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_LP_ADDRESS, + recipient: SAMPLE_ADDRESS, + token: SAMPLE_TOKEN, + amount: 500000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + rewardTimelock: 0n, + rewardRecipient: ZERO_ADDRESS, + rewardToken: ZERO_ADDRESS, + status: 1n, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.sender).toBe(SAMPLE_LP_ADDRESS) + }) + + it('filters by solverAddress case-insensitively', async () => { + // count = 1 + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(1) + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12', + recipient: SAMPLE_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + rewardTimelock: 0n, + rewardRecipient: ZERO_ADDRESS, + rewardToken: ZERO_ADDRESS, + status: 1n, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails( + { ...lockParams, solverAddress: '0xabcdef1234567890abcdef1234567890abcdef12' }, + nodeUrl, + ) + + expect(result).not.toBeNull() + }) + + it('returns null when no locks match solverAddress', async () => { + ; (AbiFunction.decodeResult as any).mockReturnValueOnce(1) + ; (AbiFunction.decodeResult as any).mockReturnValueOnce({ + sender: SAMPLE_LP_ADDRESS, + recipient: SAMPLE_ADDRESS, + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + rewardTimelock: 0n, + rewardRecipient: ZERO_ADDRESS, + rewardToken: ZERO_ADDRESS, + status: 1n, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails( + { ...lockParams, solverAddress: '0xnonmatchingaddress' }, + nodeUrl, + ) + + expect(result).toBeNull() + }) + }) + + describe('recoverSwap', () => { + it('returns RecoveredSwapData from receipt and tx', async () => { + const receipt = { + logs: [{ data: '0x', topics: ['0xtopic'], address: SAMPLE_CONTRACT, logIndex: '0x0', blockNumber: '0x10', transactionHash: SAMPLE_TX_HASH }], + blockNumber: '0x10', + transactionHash: SAMPLE_TX_HASH, + status: '0x1', + } + const tx = { to: SAMPLE_CONTRACT, from: SAMPLE_ADDRESS, hash: SAMPLE_TX_HASH, input: '0x', value: '0x0', blockNumber: '0x10' } + + mockRpc.getTransactionReceipt.mockResolvedValueOnce(receipt) + mockRpc.getTransaction.mockResolvedValueOnce(tx) + + ; (AbiEvent.decode as any).mockReturnValueOnce({ + hashlock: SAMPLE_HASHLOCK, + sender: SAMPLE_ADDRESS, + recipient: SAMPLE_LP_ADDRESS, + srcChain: 'eip155:11155111', + dstChain: 'eip155:1', + token: SAMPLE_TOKEN, + amount: 1000000000000000000n, + dstAddress: '0xdest', + dstAmount: 1000000000000000000n, + dstToken: '0xdsttoken', + }) + + const { client } = createClient() + const result = await client.recoverSwap(SAMPLE_TX_HASH) + + expect(result).toMatchObject({ + hashlock: SAMPLE_HASHLOCK, + sender: SAMPLE_ADDRESS, + srcContract: SAMPLE_CONTRACT, + }) + }) + + it('throws when transaction not found', async () => { + mockRpc.getTransactionReceipt.mockResolvedValueOnce(null) + mockRpc.getTransaction.mockResolvedValueOnce(null) + + const { client } = createClient() + + await expect(client.recoverSwap(SAMPLE_TX_HASH)).rejects.toThrow('Transaction not found') + }) + + it('throws when no UserLocked event in logs', async () => { + const receipt = { logs: [], blockNumber: '0x10', transactionHash: SAMPLE_TX_HASH, status: '0x1' } + const tx = { to: SAMPLE_CONTRACT, from: SAMPLE_ADDRESS, hash: SAMPLE_TX_HASH, input: '0x', value: '0x0', blockNumber: '0x10' } + + mockRpc.getTransactionReceipt.mockResolvedValueOnce(receipt) + mockRpc.getTransaction.mockResolvedValueOnce(tx) + + const { client } = createClient() + + await expect(client.recoverSwap(SAMPLE_TX_HASH)).rejects.toThrow( + 'This transaction does not contain a swap lock' + ) + }) + }) +}) diff --git a/packages/blockchains/evm/src/__tests__/helpers.ts b/packages/blockchains/evm/src/__tests__/helpers.ts new file mode 100644 index 0000000..d928f16 --- /dev/null +++ b/packages/blockchains/evm/src/__tests__/helpers.ts @@ -0,0 +1,57 @@ +import { vi } from 'vitest' +import type { EvmSigner } from '../types.js' +import type { TrainApiClient } from '@train-protocol/sdk' + +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' + +export const SAMPLE_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' +export const SAMPLE_LP_ADDRESS = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' +export const SAMPLE_CONTRACT = '0x9999999999999999999999999999999999999999' +export const SAMPLE_TOKEN = '0x5555555555555555555555555555555555555555' +export const SAMPLE_HASHLOCK = '0x' + 'aa'.repeat(32) +export const SAMPLE_TX_HASH = '0x' + 'bb'.repeat(32) + +export function createMockSigner(overrides?: Partial): EvmSigner { + return { + address: SAMPLE_ADDRESS, + sendTransaction: vi.fn().mockResolvedValue(SAMPLE_TX_HASH), + ...overrides, + } +} + +export function createMockApiClient(): TrainApiClient { + return { + revealSecret: vi.fn().mockResolvedValue(undefined), + } as unknown as TrainApiClient +} + +export function createMockRpc(): Record> { + return { + ethCall: vi.fn().mockResolvedValue('0x'), + getTransactionReceipt: vi.fn().mockResolvedValue(null), + getTransaction: vi.fn().mockResolvedValue(null), + getBlockByNumber: vi.fn().mockResolvedValue(null), + } +} + +export function createSuccessfulFetchResponse(result: unknown) { + return { + ok: true, + json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, result }), + } +} + +export function createErrorFetchResponse(code: number, message: string, data?: unknown) { + return { + ok: true, + json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, error: { code, message, data } }), + } +} + +export function createHttpErrorResponse(status: number, statusText: string) { + return { + ok: false, + status, + statusText, + } +} diff --git a/packages/blockchains/evm/src/__tests__/rpc.test.ts b/packages/blockchains/evm/src/__tests__/rpc.test.ts new file mode 100644 index 0000000..0043db7 --- /dev/null +++ b/packages/blockchains/evm/src/__tests__/rpc.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { JsonRpcClient, JsonRpcError } from '../rpc.js' +import { + createSuccessfulFetchResponse, + createErrorFetchResponse, + createHttpErrorResponse, +} from './helpers.js' + +const RPC_URL = 'https://rpc.example.com' + +describe('JsonRpcClient', () => { + let rpc: JsonRpcClient + let mockFetch: ReturnType + + beforeEach(() => { + mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + rpc = new JsonRpcClient(RPC_URL) + }) + + describe('call', () => { + it('sends POST with correct JSON-RPC envelope', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse('0x1')) + + await rpc.call('eth_blockNumber', []) + + expect(mockFetch).toHaveBeenCalledWith(RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: expect.stringContaining('"method":"eth_blockNumber"'), + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.jsonrpc).toBe('2.0') + expect(body.method).toBe('eth_blockNumber') + expect(body.params).toEqual([]) + expect(typeof body.id).toBe('number') + }) + + it('returns json.result on success', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse('0xdeadbeef')) + + const result = await rpc.call('eth_call', [{ to: '0x1' }, 'latest']) + expect(result).toBe('0xdeadbeef') + }) + + it('throws JsonRpcError when response contains error', async () => { + mockFetch.mockResolvedValue( + createErrorFetchResponse(-32000, 'execution reverted', '0xrevert') + ) + + await expect(rpc.call('eth_call', [])).rejects.toThrow(JsonRpcError) + await expect(rpc.call('eth_call', [])).rejects.toMatchObject({ + message: 'execution reverted', + code: -32000, + data: '0xrevert', + }) + }) + + it('throws on non-OK HTTP status', async () => { + mockFetch.mockResolvedValue(createHttpErrorResponse(500, 'Internal Server Error')) + + await expect(rpc.call('eth_call', [])).rejects.toThrow( + 'RPC HTTP error: 500 Internal Server Error' + ) + }) + }) + + describe('ethCall', () => { + it('builds callObj with to and data only when from/value not provided', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse('0x')) + + await rpc.ethCall('0xcontract', '0xcalldata') + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.params[0]).toEqual({ to: '0xcontract', data: '0xcalldata' }) + expect(body.params[1]).toBe('latest') + }) + + it('includes from in callObj when provided', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse('0x')) + + await rpc.ethCall('0xcontract', '0xcalldata', '0xsender') + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.params[0].from).toBe('0xsender') + }) + + it('includes hex-encoded value when bigint provided', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse('0x')) + + await rpc.ethCall('0xcontract', '0xcalldata', '0xsender', 1000000000000000000n) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.params[0].value).toBe('0xde0b6b3a7640000') + }) + + it('uses custom blockTag when specified', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse('0x')) + + await rpc.ethCall('0xcontract', '0xcalldata', undefined, undefined, '0x10') + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.params[1]).toBe('0x10') + }) + }) + + describe('getBlockByNumber', () => { + it('calls eth_getBlockByNumber with fullTxs=false by default', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse(null)) + + await rpc.getBlockByNumber('0x10') + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.method).toBe('eth_getBlockByNumber') + expect(body.params).toEqual(['0x10', false]) + }) + + it('passes fullTxs=true when requested', async () => { + mockFetch.mockResolvedValue(createSuccessfulFetchResponse(null)) + + await rpc.getBlockByNumber('0x10', true) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.params).toEqual(['0x10', true]) + }) + }) +}) diff --git a/packages/blockchains/evm/src/__tests__/wallet-sign.test.ts b/packages/blockchains/evm/src/__tests__/wallet-sign.test.ts new file mode 100644 index 0000000..99c0682 --- /dev/null +++ b/packages/blockchains/evm/src/__tests__/wallet-sign.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock SDK before importing wallet-sign +vi.mock('@train-protocol/sdk', () => ({ + deriveKeyMaterial: vi.fn().mockReturnValue(new Uint8Array(32).fill(0xab)), + IDENTITY_SALT: 'train-identity-salt', +})) + +import { deriveKeyFromEvmSignature } from '../login/wallet-sign.js' +import type { Eip1193Provider } from '../login/wallet-sign.js' +import { deriveKeyMaterial } from '@train-protocol/sdk' + +function createMockProvider(overrides?: Partial): Eip1193Provider { + return { + request: vi.fn().mockResolvedValue('0x' + 'ab'.repeat(65)), + ...overrides, + } +} + +describe('deriveKeyFromEvmSignature', () => { + let mockProvider: Eip1193Provider + + beforeEach(() => { + vi.clearAllMocks() + mockProvider = createMockProvider() + }) + + it('returns Buffer from deriveKeyMaterial', async () => { + const result = await deriveKeyFromEvmSignature(mockProvider, '0xabc') + + expect(Buffer.isBuffer(result)).toBe(true) + expect(deriveKeyMaterial).toHaveBeenCalled() + }) + + it('switches chain before signing when currentChainId differs', async () => { + const calls: string[] = [] + const provider = createMockProvider({ + request: vi.fn().mockImplementation(async (args: { method: string }) => { + calls.push(args.method) + if (args.method === 'eth_signTypedData_v4') return '0x' + 'ab'.repeat(65) + }), + }) + + await deriveKeyFromEvmSignature(provider, '0xabc', { + sandbox: false, + currentChainId: 137, + }) + + expect(calls[0]).toBe('wallet_switchEthereumChain') + expect(calls[1]).toBe('eth_signTypedData_v4') + }) + + it('does not switch chain when already on correct chain', async () => { + await deriveKeyFromEvmSignature(mockProvider, '0xabc', { + sandbox: false, + currentChainId: 1, + }) + + // Should only call signTypedData, not switchChain + const methods = (mockProvider.request as any).mock.calls.map( + (c: any) => c[0].method + ) + expect(methods).not.toContain('wallet_switchEthereumChain') + }) + + it('throws descriptive error when chain switch fails', async () => { + const provider = createMockProvider({ + request: vi.fn().mockRejectedValue(new Error('user rejected')), + }) + + await expect( + deriveKeyFromEvmSignature(provider, '0xabc', { + sandbox: false, + currentChainId: 137, + }) + ).rejects.toThrow('Please switch to Mainnet') + }) + + it('throws descriptive error when signing fails', async () => { + const provider = createMockProvider({ + request: vi.fn().mockRejectedValue(new Error('user denied')), + }) + + await expect( + deriveKeyFromEvmSignature(provider, '0xabc') + ).rejects.toThrow('Signing failed') + }) + + it('strips 0x prefix from signature before creating Buffer', async () => { + const provider = createMockProvider({ + request: vi.fn().mockResolvedValue('0xaabbccdd'), + }) + + await deriveKeyFromEvmSignature(provider, '0xabc') + + // deriveKeyMaterial should receive the raw hex bytes (without 0x) + const inputMaterial = (deriveKeyMaterial as any).mock.calls[0][0] + expect(inputMaterial).toEqual(Buffer.from('aabbccdd', 'hex')) + }) +}) diff --git a/packages/blockchains/solana/src/__tests__/client.test.ts b/packages/blockchains/solana/src/__tests__/client.test.ts index 35beeaf..190166d 100644 --- a/packages/blockchains/solana/src/__tests__/client.test.ts +++ b/packages/blockchains/solana/src/__tests__/client.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => { const chain: any = { accounts: vi.fn(), instruction: vi.fn().mockResolvedValue({}), + transaction: vi.fn().mockResolvedValue({}), } chain.accounts.mockReturnValue(chain) return chain @@ -30,11 +31,19 @@ const mocks = vi.hoisted(() => { return { program: { methods: { + userLockSol: vi.fn().mockReturnValue(makeChain()), + userLockToken: vi.fn().mockReturnValue(makeChain()), refundUserSol: vi.fn().mockReturnValue(makeChain()), refundUserToken: vi.fn().mockReturnValue(makeChain()), closeUserLock: vi.fn().mockReturnValue(makeChain()), + redeemSolverSol: vi.fn().mockReturnValue(makeChain()), + redeemSolverToken: vi.fn().mockReturnValue(makeChain()), + getSolverLockCount: vi.fn().mockReturnValue({ view: vi.fn().mockResolvedValue(0) }), + }, + account: { + userLock: { fetch: vi.fn() }, + solverLock: { fetch: vi.fn() }, }, - account: { userLock: { fetch: vi.fn() } }, programId: null as any, coder: { events: { decode: vi.fn().mockReturnValue(null) } }, }, @@ -91,9 +100,15 @@ describe('SolanaHTLCClient', () => { mocks.connection.getLatestBlockhash.mockResolvedValue({ blockhash: BLOCKHASH, lastValidBlockHeight: 100 }) mocks.connection.confirmTransaction.mockResolvedValue({ value: { err: null } }) mocks.program.coder.events.decode.mockReturnValue(null) + mocks.program.methods.userLockSol.mockReturnValue(mocks.makeChain()) + mocks.program.methods.userLockToken.mockReturnValue(mocks.makeChain()) mocks.program.methods.refundUserSol.mockReturnValue(mocks.makeChain()) mocks.program.methods.refundUserToken.mockReturnValue(mocks.makeChain()) mocks.program.methods.closeUserLock.mockReturnValue(mocks.makeChain()) + mocks.program.methods.redeemSolverSol.mockReturnValue(mocks.makeChain()) + mocks.program.methods.redeemSolverToken.mockReturnValue(mocks.makeChain()) + mocks.program.methods.getSolverLockCount.mockReturnValue({ view: vi.fn().mockResolvedValue(0) }) + mocks.program.account.solverLock.fetch.mockResolvedValue(null) sendTransaction = vi.fn().mockResolvedValue(SIGNATURE) client = new SolanaHTLCClient({ @@ -274,4 +289,325 @@ describe('SolanaHTLCClient', () => { await expect(client.refund(baseParams)).rejects.toThrow('InstructionError') }) }) + + // ── userLock ────────────────────────────────────────────────────────────── + + describe('userLock', () => { + const baseParams = { + destinationChain: 'eip155:1', + sourceChain: 'solana:devnet', + amount: '2.0', + destinationAmount: '2000000000000000000', + decimals: 9, + destinationAsset: '0xdsttoken', + sourceAsset: { contractAddress: '', symbol: 'SOL', decimals: 9 }, + destLpAddress: RECIPIENT_KEY, + srcLpAddress: RECIPIENT_KEY, + atomicContract: CONTRACT, + sourceAddress: SIGNER_KEY, + destinationAddress: '0xdestaddr', + hashlock: HASHLOCK, + nonce: 1700000000000, + quoteExpiry: 1700001000, + timelockDelta: 150, + } as any + + it('throws when signer is not configured', async () => { + const noSignerClient = new SolanaHTLCClient({ + rpcUrl: 'https://api.devnet.solana.com', + apiClient: mockApiClient, + }) + await expect(noSignerClient.userLock(baseParams)).rejects.toThrow('Solana signer not configured') + }) + + it('throws when contractAddress is missing', async () => { + await expect( + client.userLock({ ...baseParams, atomicContract: '' }) + ).rejects.toThrow('No contract address') + }) + + it('returns hash, hashlock, and nonce for SOL lock', async () => { + const result = await client.userLock(baseParams) + + expect(result.hash).toBe(SIGNATURE) + expect(result.hashlock).toBe(HASHLOCK) + expect(result.nonce).toBe(1700000000000) + expect(mocks.program.methods.userLockSol).toHaveBeenCalled() + expect(sendTransaction).toHaveBeenCalledOnce() + }) + + it('uses userLockToken for token locks', async () => { + const result = await client.userLock({ + ...baseParams, + sourceAsset: { contractAddress: TOKEN_MINT, symbol: 'USDC', decimals: 6 }, + }) + + expect(result.hash).toBe(SIGNATURE) + expect(mocks.program.methods.userLockToken).toHaveBeenCalled() + }) + + it('throws when transaction confirmation reports an error', async () => { + mocks.connection.confirmTransaction.mockResolvedValue({ value: { err: 'InstructionError' } }) + + await expect(client.userLock(baseParams)).rejects.toThrow('InstructionError') + }) + + it('propagates sendTransaction errors', async () => { + sendTransaction.mockRejectedValueOnce(new Error('wallet rejected')) + + await expect(client.userLock(baseParams)).rejects.toThrow('wallet rejected') + }) + }) + + // ── redeemSolver ───────────────────────────────────────────────────────── + + describe('redeemSolver', () => { + const baseParams = { + id: HASHLOCK, + contractAddress: CONTRACT, + secret: '12345' as string | bigint, + type: 'native' as const, + chainId: 'solana:devnet', + sourceAsset: { contractAddress: '', symbol: 'SOL', decimals: 9 }, + destLpAddress: RECIPIENT_KEY, + } + + beforeEach(() => { + // redeemSolverTransactionBuilder fetches the solver lock account + mocks.program.account.solverLock.fetch.mockResolvedValue({ + rewardRecipient: new PublicKey(RECIPIENT_KEY), + recipient: new PublicKey(SIGNER_KEY), + }) + }) + + it('throws when signer is not configured', async () => { + const noSignerClient = new SolanaHTLCClient({ + rpcUrl: 'https://api.devnet.solana.com', + apiClient: mockApiClient, + }) + await expect(noSignerClient.redeemSolver(baseParams)).rejects.toThrow('Solana signer not configured') + }) + + it('throws when contractAddress is missing', async () => { + await expect( + client.redeemSolver({ ...baseParams, contractAddress: '' }) + ).rejects.toThrow('No contract address') + }) + + it('returns signature for SOL redemption', async () => { + const result = await client.redeemSolver(baseParams) + + expect(result).toBe(SIGNATURE) + expect(mocks.program.methods.redeemSolverSol).toHaveBeenCalled() + expect(sendTransaction).toHaveBeenCalledOnce() + }) + + it('uses redeemSolverToken for token redemptions', async () => { + const result = await client.redeemSolver({ + ...baseParams, + type: 'erc20', + sourceAsset: { contractAddress: TOKEN_MINT, symbol: 'USDC', decimals: 6 }, + }) + + expect(result).toBe(SIGNATURE) + expect(mocks.program.methods.redeemSolverToken).toHaveBeenCalled() + }) + + it('throws when transaction confirmation reports an error', async () => { + mocks.connection.confirmTransaction.mockResolvedValue({ value: { err: 'InstructionError' } }) + + await expect(client.redeemSolver(baseParams)).rejects.toThrow('InstructionError') + }) + }) + + // ── _getSolverLockDetails ──────────────────────────────────────────────── + + describe('_getSolverLockDetails', () => { + const baseParams = { + id: HASHLOCK, + contractAddress: CONTRACT, + chainId: null, + decimals: 9, + } + const nodeUrl = 'https://api.devnet.solana.com' + + it('throws when contractAddress is missing', async () => { + await expect( + client._getSolverLockDetails({ ...baseParams, contractAddress: '' }, nodeUrl) + ).rejects.toThrow('No contract address') + }) + + it('returns null when count is 0', async () => { + const result = await client._getSolverLockDetails(baseParams, nodeUrl) + expect(result).toBeNull() + }) + + it('returns first valid solver lock', async () => { + mocks.program.methods.getSolverLockCount.mockReturnValue({ + view: vi.fn().mockResolvedValue(1), + }) + mocks.program.account.solverLock.fetch.mockResolvedValue({ + amount: new BN(2_000_000_000), + reward: new BN(0), + timelock: new BN(1_700_000_000), + rewardTimelock: new BN(0), + sender: new PublicKey(SENDER_KEY), + recipient: new PublicKey(RECIPIENT_KEY), + rewardRecipient: new PublicKey(RECIPIENT_KEY), + secret: new Array(32).fill(0), + tokenMint: new PublicKey('11111111111111111111111111111111'), + rewardTokenMint: new PublicKey('11111111111111111111111111111111'), + status: LockStatus.Pending, + }) + + const result = await client._getSolverLockDetails(baseParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.hashlock).toBe(HASHLOCK) + expect(result!.sender).toBe(SENDER_KEY) + expect(result!.status).toBe(LockStatus.Pending) + expect(result!.index).toBe(1) + expect(result!.token).toBeUndefined() // native SOL + }) + + it('skips empty slots (native address sender)', async () => { + mocks.program.methods.getSolverLockCount.mockReturnValue({ + view: vi.fn().mockResolvedValue(2), + }) + // First: empty slot + mocks.program.account.solverLock.fetch + .mockResolvedValueOnce({ + amount: new BN(0), + reward: new BN(0), + timelock: new BN(0), + rewardTimelock: new BN(0), + sender: new PublicKey('11111111111111111111111111111111'), + recipient: new PublicKey('11111111111111111111111111111111'), + rewardRecipient: new PublicKey('11111111111111111111111111111111'), + secret: new Array(32).fill(0), + tokenMint: new PublicKey('11111111111111111111111111111111'), + rewardTokenMint: new PublicKey('11111111111111111111111111111111'), + status: 0, + }) + // Second: valid + .mockResolvedValueOnce({ + amount: new BN(1_000_000_000), + reward: new BN(0), + timelock: new BN(1_700_000_000), + rewardTimelock: new BN(0), + sender: new PublicKey(SENDER_KEY), + recipient: new PublicKey(RECIPIENT_KEY), + rewardRecipient: new PublicKey(RECIPIENT_KEY), + secret: new Array(32).fill(0), + tokenMint: new PublicKey('11111111111111111111111111111111'), + rewardTokenMint: new PublicKey('11111111111111111111111111111111'), + status: LockStatus.Pending, + }) + + const result = await client._getSolverLockDetails(baseParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.index).toBe(2) + expect(result!.sender).toBe(SENDER_KEY) + }) + + it('filters by solverAddress', async () => { + mocks.program.methods.getSolverLockCount.mockReturnValue({ + view: vi.fn().mockResolvedValue(1), + }) + mocks.program.account.solverLock.fetch.mockResolvedValue({ + amount: new BN(1_000_000_000), + reward: new BN(0), + timelock: new BN(1_700_000_000), + rewardTimelock: new BN(0), + sender: new PublicKey(SENDER_KEY), + recipient: new PublicKey(RECIPIENT_KEY), + rewardRecipient: new PublicKey(RECIPIENT_KEY), + secret: new Array(32).fill(0), + tokenMint: new PublicKey('11111111111111111111111111111111'), + rewardTokenMint: new PublicKey('11111111111111111111111111111111'), + status: LockStatus.Pending, + }) + + const result = await client._getSolverLockDetails( + { ...baseParams, solverAddress: 'NonMatchingAddress' }, + nodeUrl, + ) + + expect(result).toBeNull() + }) + + it('returns token address for non-native mints', async () => { + mocks.program.methods.getSolverLockCount.mockReturnValue({ + view: vi.fn().mockResolvedValue(1), + }) + mocks.program.account.solverLock.fetch.mockResolvedValue({ + amount: new BN(5_000_000), + reward: new BN(100_000), + timelock: new BN(1_700_000_000), + rewardTimelock: new BN(1_700_001_000), + sender: new PublicKey(SENDER_KEY), + recipient: new PublicKey(RECIPIENT_KEY), + rewardRecipient: new PublicKey(RECIPIENT_KEY), + secret: new Array(32).fill(0), + tokenMint: new PublicKey(TOKEN_MINT), + rewardTokenMint: new PublicKey(TOKEN_MINT), + status: LockStatus.Pending, + }) + + const result = await client._getSolverLockDetails(baseParams, nodeUrl) + + expect(result!.token).toBe(TOKEN_MINT) + expect(result!.rewardToken).toBe(TOKEN_MINT) + }) + + it('handles fetch errors gracefully', async () => { + mocks.program.methods.getSolverLockCount.mockReturnValue({ + view: vi.fn().mockResolvedValue(1), + }) + mocks.program.account.solverLock.fetch.mockRejectedValue(new Error('RPC error')) + + const result = await client._getSolverLockDetails(baseParams, nodeUrl) + + expect(result).toBeNull() + }) + + it('returns secret when non-zero', async () => { + mocks.program.methods.getSolverLockCount.mockReturnValue({ + view: vi.fn().mockResolvedValue(1), + }) + const secretBytes = new Array(32).fill(0) + secretBytes[31] = 42 + + mocks.program.account.solverLock.fetch.mockResolvedValue({ + amount: new BN(1_000_000_000), + reward: new BN(0), + timelock: new BN(1_700_000_000), + rewardTimelock: new BN(0), + sender: new PublicKey(SENDER_KEY), + recipient: new PublicKey(RECIPIENT_KEY), + rewardRecipient: new PublicKey(RECIPIENT_KEY), + secret: secretBytes, + tokenMint: new PublicKey('11111111111111111111111111111111'), + rewardTokenMint: new PublicKey('11111111111111111111111111111111'), + status: LockStatus.Redeemed, + }) + + const result = await client._getSolverLockDetails(baseParams, nodeUrl) + + expect(result!.secret).toBeDefined() + expect(typeof result!.secret).toBe('bigint') + expect(result!.status).toBe(LockStatus.Redeemed) + }) + }) + + // ── recoverSwap ────────────────────────────────────────────────────────── + + describe('recoverSwap', () => { + it('throws not supported', async () => { + await expect(client.recoverSwap('0xabc')).rejects.toThrow( + 'recoverSwap is not supported for Solana' + ) + }) + }) }) diff --git a/packages/blockchains/solana/src/__tests__/wallet-sign.test.ts b/packages/blockchains/solana/src/__tests__/wallet-sign.test.ts new file mode 100644 index 0000000..fa88290 --- /dev/null +++ b/packages/blockchains/solana/src/__tests__/wallet-sign.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@train-protocol/sdk', () => ({ + deriveKeyMaterial: vi.fn().mockReturnValue(new Uint8Array(32).fill(0xab)), + IDENTITY_SALT: 'train-identity-salt', +})) + +import { deriveKeyFromSolanaWallet, type SolanaWalletLike } from '../login/wallet-sign.js' +import { deriveKeyMaterial } from '@train-protocol/sdk' + +function createMockWallet(overrides?: Partial): SolanaWalletLike { + return { + signMessage: vi.fn().mockResolvedValue(new Uint8Array(64).fill(0xcc)), + ...overrides, + } +} + +describe('deriveKeyFromSolanaWallet', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(deriveKeyMaterial as any).mockReturnValue(new Uint8Array(32).fill(0xab)) + }) + + it('returns Buffer from deriveKeyMaterial', async () => { + const wallet = createMockWallet() + const result = await deriveKeyFromSolanaWallet(wallet) + + expect(Buffer.isBuffer(result)).toBe(true) + expect(deriveKeyMaterial).toHaveBeenCalled() + }) + + it('throws when wallet is falsy', async () => { + await expect( + deriveKeyFromSolanaWallet(null as any) + ).rejects.toThrow('Solana wallet does not support message signing') + }) + + it('throws when wallet has no signMessage method', async () => { + await expect( + deriveKeyFromSolanaWallet({} as any) + ).rejects.toThrow('Solana wallet does not support message signing') + }) + + it('passes signature bytes to deriveKeyMaterial', async () => { + const mockSig = new Uint8Array(64).fill(0xff) + const wallet = createMockWallet({ + signMessage: vi.fn().mockResolvedValue(mockSig), + }) + + await deriveKeyFromSolanaWallet(wallet) + + const inputMaterial = (deriveKeyMaterial as any).mock.calls[0][0] + expect(inputMaterial).toEqual(mockSig) + }) +}) diff --git a/packages/blockchains/starknet/src/__tests__/client.test.ts b/packages/blockchains/starknet/src/__tests__/client.test.ts new file mode 100644 index 0000000..a1f1ba2 --- /dev/null +++ b/packages/blockchains/starknet/src/__tests__/client.test.ts @@ -0,0 +1,467 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { LockStatus } from '@train-protocol/sdk' +import { + SAMPLE_ADDRESS, + SAMPLE_LP_ADDRESS, + SAMPLE_CONTRACT, + SAMPLE_TOKEN, + SAMPLE_HASHLOCK, + SAMPLE_TX_HASH, + createMockSigner, + createMockApiClient, +} from './helpers.js' + +// Create mock contract instance +const mockContract = { + populate: vi.fn().mockReturnValue({ calldata: [] }), + invoke: vi.fn().mockResolvedValue({ transaction_hash: SAMPLE_TX_HASH }), + get_user_lock: vi.fn().mockResolvedValue({}), + get_solver_lock: vi.fn().mockResolvedValue({}), + get_solver_lock_count: vi.fn().mockResolvedValue(0n), +} + +vi.mock('starknet', () => ({ + Contract: class { + constructor() { return mockContract } + }, + RpcProvider: class { + constructor() {} + }, + cairo: { + uint256: vi.fn((v: unknown) => v), + }, +})) + +// Mock ABIs +vi.mock('../abis/STARKNET_HTLC.json', () => ({ default: [] })) +vi.mock('../abis/ERC20.js', () => ({ ERC20_ABI: [] })) + +import { StarknetHTLCClient } from '../client.js' + +function createClient(opts?: { withSigner?: boolean }) { + const signer = opts?.withSigner !== false ? createMockSigner() : undefined + return { + client: new StarknetHTLCClient({ + rpcUrl: 'https://starknet.example.com', + apiClient: createMockApiClient(), + signer: signer as any, + }), + signer, + } +} + +describe('StarknetHTLCClient', () => { + beforeEach(() => { + vi.resetAllMocks() + // Re-set defaults + mockContract.populate.mockReturnValue({ calldata: [] }) + mockContract.invoke.mockResolvedValue({ transaction_hash: SAMPLE_TX_HASH }) + mockContract.get_user_lock.mockResolvedValue({}) + mockContract.get_solver_lock.mockResolvedValue({}) + mockContract.get_solver_lock_count.mockResolvedValue(0n) + }) + + describe('userLock', () => { + const params = { + destinationChain: 'eip155:1', + sourceChain: 'starknet:SN_SEPOLIA', + amount: '1.0', + destinationAmount: '1000000000000000000', + decimals: 18, + destinationAsset: '0xdsttoken', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'ETH', decimals: 18 }, + destLpAddress: SAMPLE_LP_ADDRESS, + srcLpAddress: SAMPLE_LP_ADDRESS, + atomicContract: SAMPLE_CONTRACT, + sourceAddress: SAMPLE_ADDRESS, + destinationAddress: '0xdestaddr', + hashlock: SAMPLE_HASHLOCK, + nonce: 1700000000000, + quoteExpiry: 1700001000, + timelockDelta: 150, + } as any + + it('executes multicall with approve + userLock and returns result', async () => { + const { client, signer } = createClient() + + const result = await client.userLock(params) + + expect(signer!.account.execute).toHaveBeenCalledWith( + expect.arrayContaining([expect.anything(), expect.anything()]) + ) + expect(signer!.account.waitForTransaction).toHaveBeenCalledWith(SAMPLE_TX_HASH) + expect(result.hash).toBe(SAMPLE_TX_HASH) + expect(result.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result.nonce).toBe(1700000000000) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + await expect(client.userLock(params)).rejects.toThrow('Signer required') + }) + + it('propagates execution errors', async () => { + const { client, signer } = createClient() + signer!.account.execute.mockRejectedValueOnce(new Error('insufficient funds')) + + await expect(client.userLock(params)).rejects.toThrow('insufficient funds') + }) + }) + + describe('refund', () => { + const params = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + type: 'erc20' as const, + chainId: 'SN_SEPOLIA', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'ETH', decimals: 18 }, + } + + it('invokes refund_user and returns tx hash', async () => { + const { client } = createClient() + + const result = await client.refund(params) + + expect(mockContract.invoke).toHaveBeenCalledWith( + 'refund_user', + expect.anything(), + ) + expect(result).toBe(SAMPLE_TX_HASH) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + await expect(client.refund(params)).rejects.toThrow('Signer required') + }) + }) + + describe('redeemSolver', () => { + const params = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + secret: '12345' as string | bigint, + type: 'erc20' as const, + chainId: 'SN_SEPOLIA', + sourceAsset: { contractAddress: SAMPLE_TOKEN, symbol: 'ETH', decimals: 18 }, + destLpAddress: SAMPLE_LP_ADDRESS, + } + + it('invokes redeem_solver and returns tx hash', async () => { + const { client } = createClient() + + const result = await client.redeemSolver(params) + + expect(mockContract.invoke).toHaveBeenCalledWith( + 'redeem_solver', + expect.anything(), + ) + expect(result).toBe(SAMPLE_TX_HASH) + }) + + it('throws when no signer configured', async () => { + const { client } = createClient({ withSigner: false }) + await expect(client.redeemSolver(params)).rejects.toThrow('Signer required') + }) + }) + + describe('getUserLockDetails', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: 'SN_SEPOLIA', + } + + it('returns null when sender is zero', async () => { + mockContract.get_user_lock.mockResolvedValueOnce({ + sender: 0n, + recipient: 0n, + token: 0n, + amount: 0n, + secret: 0n, + timelock: 0n, + status: 0, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result).toBeNull() + }) + + it('returns mapped lock details when lock exists', async () => { + mockContract.get_user_lock.mockResolvedValueOnce({ + sender: BigInt(SAMPLE_ADDRESS), + recipient: BigInt(SAMPLE_LP_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + status: { activeVariant: () => 'Pending' }, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result).not.toBeNull() + expect(result!.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result!.timelock).toBe(1700001000) + expect(result!.status).toBe(LockStatus.Pending) + expect(result!.secret).toBeUndefined() + }) + + it('returns secret when non-zero', async () => { + mockContract.get_user_lock.mockResolvedValueOnce({ + sender: BigInt(SAMPLE_ADDRESS), + recipient: BigInt(SAMPLE_LP_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 1000000000000000000n, + secret: 42n, + timelock: 1700001000n, + status: { activeVariant: () => 'Redeemed' }, + }) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result!.secret).toBe(42n) + expect(result!.status).toBe(LockStatus.Redeemed) + }) + + it('handles errors gracefully and returns null', async () => { + mockContract.get_user_lock.mockRejectedValueOnce(new Error('network error')) + + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + + expect(result).toBeNull() + }) + }) + + describe('_getSolverLockDetails', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: 'SN_SEPOLIA', + } + const nodeUrl = 'https://starknet-node.example.com' + + it('returns null when count is 0', async () => { + mockContract.get_solver_lock_count.mockResolvedValueOnce(0n) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).toBeNull() + }) + + it('returns first valid solver lock', async () => { + mockContract.get_solver_lock_count.mockResolvedValueOnce(1n) + mockContract.get_solver_lock.mockResolvedValueOnce({ + sender: BigInt(SAMPLE_LP_ADDRESS), + recipient: BigInt(SAMPLE_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: 0n, + reward_token: 0n, + status: { activeVariant: () => 'Pending' }, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.hashlock).toBe(SAMPLE_HASHLOCK) + expect(result!.status).toBe(LockStatus.Pending) + expect(result!.index).toBe(1) + }) + + it('skips locks with zero sender', async () => { + mockContract.get_solver_lock_count.mockResolvedValueOnce(2n) + // First lock: zero sender + mockContract.get_solver_lock.mockResolvedValueOnce({ + sender: 0n, + recipient: 0n, + token: 0n, + amount: 0n, + secret: 0n, + timelock: 0n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: 0n, + reward_token: 0n, + status: 0, + }) + // Second lock: valid + mockContract.get_solver_lock.mockResolvedValueOnce({ + sender: BigInt(SAMPLE_LP_ADDRESS), + recipient: BigInt(SAMPLE_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 500000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: 0n, + reward_token: 0n, + status: { activeVariant: () => 'Pending' }, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails(lockParams, nodeUrl) + + expect(result).not.toBeNull() + expect(result!.index).toBe(2) + }) + + it('filters by solverAddress case-insensitively', async () => { + const solverAddr = '0x' + BigInt(SAMPLE_LP_ADDRESS).toString(16) + + mockContract.get_solver_lock_count.mockResolvedValueOnce(1n) + mockContract.get_solver_lock.mockResolvedValueOnce({ + sender: BigInt(SAMPLE_LP_ADDRESS), + recipient: BigInt(SAMPLE_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: 0n, + reward_token: 0n, + status: { activeVariant: () => 'Pending' }, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails( + { ...lockParams, solverAddress: solverAddr.toUpperCase() }, + nodeUrl, + ) + + expect(result).not.toBeNull() + }) + + it('returns null when no locks match solverAddress', async () => { + mockContract.get_solver_lock_count.mockResolvedValueOnce(1n) + mockContract.get_solver_lock.mockResolvedValueOnce({ + sender: BigInt(SAMPLE_LP_ADDRESS), + recipient: BigInt(SAMPLE_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + reward: 0n, + reward_timelock: 0n, + reward_recipient: 0n, + reward_token: 0n, + status: { activeVariant: () => 'Pending' }, + }) + + const { client } = createClient() + const result = await client._getSolverLockDetails( + { ...lockParams, solverAddress: '0xnonmatching' }, + nodeUrl, + ) + + expect(result).toBeNull() + }) + }) + + describe('mapLockStatus (via getUserLockDetails)', () => { + const lockParams = { + id: SAMPLE_HASHLOCK, + contractAddress: SAMPLE_CONTRACT, + chainId: 'SN_SEPOLIA', + } + + function makeLockResult(status: unknown) { + return { + sender: BigInt(SAMPLE_ADDRESS), + recipient: BigInt(SAMPLE_LP_ADDRESS), + token: BigInt(SAMPLE_TOKEN), + amount: 1000000000000000000n, + secret: 0n, + timelock: 1700001000n, + status, + } + } + + it('handles CairoCustomEnum with activeVariant() - Pending', async () => { + mockContract.get_user_lock.mockResolvedValueOnce( + makeLockResult({ activeVariant: () => 'Pending' }) + ) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(LockStatus.Pending) + }) + + it('handles CairoCustomEnum with activeVariant() - Redeemed', async () => { + mockContract.get_user_lock.mockResolvedValueOnce( + makeLockResult({ activeVariant: () => 'Redeemed' }) + ) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(LockStatus.Redeemed) + }) + + it('handles CairoCustomEnum with activeVariant() - Refunded', async () => { + mockContract.get_user_lock.mockResolvedValueOnce( + makeLockResult({ activeVariant: () => 'Refunded' }) + ) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(LockStatus.Refunded) + }) + + it('handles unknown activeVariant as Empty', async () => { + mockContract.get_user_lock.mockResolvedValueOnce( + makeLockResult({ activeVariant: () => 'Unknown' }) + ) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(LockStatus.Empty) + }) + + it('handles plain number fallback', async () => { + mockContract.get_user_lock.mockResolvedValueOnce(makeLockResult(1)) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(1) + }) + + it('handles plain bigint fallback', async () => { + mockContract.get_user_lock.mockResolvedValueOnce(makeLockResult(2n)) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(2) + }) + + it('handles { variant: { Refunded: {} } } object shape', async () => { + mockContract.get_user_lock.mockResolvedValueOnce( + makeLockResult({ variant: { Refunded: {} } }) + ) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(LockStatus.Refunded) + }) + + it('returns Empty for unrecognized input', async () => { + mockContract.get_user_lock.mockResolvedValueOnce(makeLockResult('garbage')) + const { client } = createClient() + const result = await client.getUserLockDetails(lockParams) + expect(result!.status).toBe(LockStatus.Empty) + }) + }) + + describe('recoverSwap', () => { + it('throws not supported', async () => { + const { client } = createClient() + await expect(client.recoverSwap('0xabc')).rejects.toThrow( + 'recoverSwap is not supported for Starknet' + ) + }) + }) +}) diff --git a/packages/blockchains/starknet/src/__tests__/helpers.ts b/packages/blockchains/starknet/src/__tests__/helpers.ts new file mode 100644 index 0000000..ebcfb79 --- /dev/null +++ b/packages/blockchains/starknet/src/__tests__/helpers.ts @@ -0,0 +1,32 @@ +import { vi } from 'vitest' +import type { TrainApiClient } from '@train-protocol/sdk' + +export const SAMPLE_ADDRESS = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7' +export const SAMPLE_LP_ADDRESS = '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +export const SAMPLE_CONTRACT = '0x0999999999999999999999999999999999999999999999999999999999999999' +export const SAMPLE_TOKEN = '0x0555555555555555555555555555555555555555555555555555555555555555' +export const SAMPLE_HASHLOCK = '0x' + 'aa'.repeat(32) +export const SAMPLE_TX_HASH = '0x' + 'bb'.repeat(32) + +export function createMockAccount(): Record> { + return { + execute: vi.fn().mockResolvedValue({ transaction_hash: SAMPLE_TX_HASH }), + waitForTransaction: vi.fn().mockResolvedValue({}), + signMessage: vi.fn().mockResolvedValue(['0x123', '0x456']), + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createMockSigner(accountOverrides?: Record): { address: string; account: any } { + const account = { ...createMockAccount(), ...accountOverrides } + return { + address: SAMPLE_ADDRESS, + account, + } +} + +export function createMockApiClient(): TrainApiClient { + return { + revealSecret: vi.fn().mockResolvedValue(undefined), + } as unknown as TrainApiClient +} diff --git a/packages/blockchains/starknet/src/__tests__/wallet-sign.test.ts b/packages/blockchains/starknet/src/__tests__/wallet-sign.test.ts new file mode 100644 index 0000000..f7ff3a9 --- /dev/null +++ b/packages/blockchains/starknet/src/__tests__/wallet-sign.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@train-protocol/sdk', () => ({ + deriveKeyMaterial: vi.fn().mockReturnValue(new Uint8Array(32).fill(0xab)), + IDENTITY_SALT: 'train-identity-salt', +})) + +import { deriveKeyFromStarknetWallet, type StarknetAccountLike } from '../login/wallet-sign.js' +import { deriveKeyMaterial } from '@train-protocol/sdk' + +function createMockAccount(overrides?: Partial): StarknetAccountLike { + return { + signMessage: vi.fn().mockResolvedValue(['0x1a2b3c', '0x4d5e6f']), + ...overrides, + } +} + +describe('deriveKeyFromStarknetWallet', () => { + beforeEach(() => { + vi.resetAllMocks() + ;(deriveKeyMaterial as any).mockReturnValue(new Uint8Array(32).fill(0xab)) + }) + + it('uses custom chainId when provided', async () => { + const account = createMockAccount() + + await deriveKeyFromStarknetWallet(account, '0xaddr', { chainId: 'SN_MAIN' }) + + const typedData = (account.signMessage as any).mock.calls[0][0] + expect(typedData.domain.chainId).toBe('SN_MAIN') + }) + + it('returns Buffer from deriveKeyMaterial', async () => { + const account = createMockAccount() + + const result = await deriveKeyFromStarknetWallet(account, '0xaddr') + + expect(Buffer.isBuffer(result)).toBe(true) + expect(deriveKeyMaterial).toHaveBeenCalled() + }) + + it('serializes signature array elements into bytes', async () => { + const account = createMockAccount({ + signMessage: vi.fn().mockResolvedValue(['0xff', '0xaa']), + }) + + await deriveKeyFromStarknetWallet(account, '0xaddr') + + // deriveKeyMaterial receives Buffer of serialized signature bytes + const inputMaterial = (deriveKeyMaterial as any).mock.calls[0][0] + expect(Buffer.isBuffer(inputMaterial)).toBe(true) + expect(inputMaterial.length).toBeGreaterThan(0) + }) + + it('throws when account is falsy', async () => { + await expect( + deriveKeyFromStarknetWallet(null as any, '0xaddr') + ).rejects.toThrow('Starknet wallet not connected') + }) +})