diff --git a/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.test.ts b/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.test.ts index 6dd57622..8820f913 100644 --- a/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.test.ts +++ b/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.test.ts @@ -9,6 +9,7 @@ import { isChainSelectorSupported, LAST_FINALIZED_BLOCK_NUMBER, LATEST_BLOCK_NUMBER, + logTriggerConfig, type ProtoBigInt, prepareReportRequest, protoBigIntToBigint, @@ -268,6 +269,143 @@ describe('blockchain-helpers', () => { }) }) + describe('encodeCallMsg error context', () => { + test('should include field name in error for invalid from address', () => { + const payload: EncodeCallMsgPayload = { + from: 'not-hex' as `0x${string}`, + to: '0x0000000000000000000000000000000000000000', + data: '0x', + } + expect(() => encodeCallMsg(payload)).toThrow("Invalid hex in 'from' field of CallMsg") + }) + + test('should include field name in error for invalid to address', () => { + const payload: EncodeCallMsgPayload = { + from: '0x0000000000000000000000000000000000000000', + to: 'bad-hex' as `0x${string}`, + data: '0x', + } + expect(() => encodeCallMsg(payload)).toThrow("Invalid hex in 'to' field of CallMsg") + }) + + test('should include field name in error for invalid data', () => { + const payload: EncodeCallMsgPayload = { + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: 'not-hex' as `0x${string}`, + } + expect(() => encodeCallMsg(payload)).toThrow("Invalid hex in 'data' field of CallMsg") + }) + }) + + describe('logTriggerConfig', () => { + const VALID_ADDRESS = '0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9' + const VALID_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' + + test('should encode a single address', () => { + const result = logTriggerConfig({ addresses: [VALID_ADDRESS] }) + expect(result.addresses).toHaveLength(1) + expect(typeof result.addresses![0]).toBe('string') // base64 + }) + + test('should encode multiple addresses', () => { + const result = logTriggerConfig({ + addresses: [VALID_ADDRESS, '0x0000000000000000000000000000000000000000'], + }) + expect(result.addresses).toHaveLength(2) + }) + + test('should encode topics with correct structure', () => { + const result = logTriggerConfig({ + addresses: [VALID_ADDRESS], + topics: [[VALID_TOPIC]], + }) + expect(result.topics).toHaveLength(1) + expect(result.topics![0].values).toHaveLength(1) + expect(typeof result.topics![0]!.values![0]).toBe('string') // base64 + }) + + test('should encode multiple topic slots', () => { + const result = logTriggerConfig({ + addresses: [VALID_ADDRESS], + topics: [[VALID_TOPIC], [VALID_TOPIC, VALID_TOPIC]], + }) + expect(result.topics).toHaveLength(2) + expect(result.topics![0].values).toHaveLength(1) + expect(result.topics![1].values).toHaveLength(2) + }) + + test('should omit topics when not provided', () => { + const result = logTriggerConfig({ addresses: [VALID_ADDRESS] }) + expect(result.topics).toBeUndefined() + }) + + test('should set confidence level', () => { + const result = logTriggerConfig({ + addresses: [VALID_ADDRESS], + confidence: 'LATEST', + }) + expect(result.confidence).toBe('CONFIDENCE_LEVEL_LATEST') + }) + + test('should set FINALIZED confidence level', () => { + const result = logTriggerConfig({ + addresses: [VALID_ADDRESS], + confidence: 'FINALIZED', + }) + expect(result.confidence).toBe('CONFIDENCE_LEVEL_FINALIZED') + }) + + test('should omit confidence when not provided', () => { + const result = logTriggerConfig({ addresses: [VALID_ADDRESS] }) + expect(result.confidence).toBeUndefined() + }) + + test('should throw for empty addresses array', () => { + expect(() => logTriggerConfig({ addresses: [] })).toThrow( + 'logTriggerConfig requires at least one address', + ) + }) + + test('should throw for invalid hex address', () => { + expect(() => logTriggerConfig({ addresses: ['not-hex' as `0x${string}`] })).toThrow( + 'Invalid address at index 0', + ) + }) + + test('should throw for address with wrong byte length', () => { + expect(() => logTriggerConfig({ addresses: ['0x1234' as `0x${string}`] })).toThrow( + 'expected 20 bytes', + ) + }) + + test('should throw for topic with wrong byte length', () => { + expect(() => + logTriggerConfig({ + addresses: [VALID_ADDRESS], + topics: [['0x1234' as `0x${string}`]], + }), + ).toThrow('expected 32 bytes') + }) + + test('should include index in address error', () => { + expect(() => + logTriggerConfig({ + addresses: [VALID_ADDRESS, 'bad' as `0x${string}`], + }), + ).toThrow('Invalid address at index 1') + }) + + test('should include slot and value index in topic error', () => { + expect(() => + logTriggerConfig({ + addresses: [VALID_ADDRESS], + topics: [[VALID_TOPIC], ['bad' as `0x${string}`]], + }), + ).toThrow('Invalid topic at topics[1][0]') + }) + }) + describe('isChainSelectorSupported', () => { test('should return true for supported chain selectors', () => { // Get all supported chain selectors from EVMClient diff --git a/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.ts b/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.ts index 2ba08bb3..fb6ba509 100644 --- a/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.ts +++ b/packages/cre-sdk/src/sdk/utils/capabilities/blockchain/blockchain-helpers.ts @@ -1,9 +1,13 @@ import { create, toJson } from '@bufbuild/protobuf' -import type { CallMsgJson } from '@cre/generated/capabilities/blockchain/evm/v1alpha/client_pb' +import type { + CallMsgJson, + ConfidenceLevelJson, + FilterLogTriggerRequestJson, +} from '@cre/generated/capabilities/blockchain/evm/v1alpha/client_pb' import type { ReportRequestJson } from '@cre/generated/sdk/v1alpha/sdk_pb' import { BigIntSchema, type BigInt as GeneratedBigInt } from '@cre/generated/values/v1/values_pb' import { EVMClient } from '@cre/sdk/cre' -import { bigintToBytes, bytesToBigint, hexToBase64 } from '@cre/sdk/utils/hex-utils' +import { bigintToBytes, bytesToBigint, hexToBase64, hexToBytes } from '@cre/sdk/utils/hex-utils' import type { Address, Hex } from 'viem' /** @@ -118,11 +122,23 @@ export interface EncodeCallMsgPayload { * @param payload - The call message payload to encode. * @returns The encoded call message payload. */ -export const encodeCallMsg = (payload: EncodeCallMsgPayload): CallMsgJson => ({ - from: hexToBase64(payload.from), - to: hexToBase64(payload.to), - data: hexToBase64(payload.data), -}) +export const encodeCallMsg = (payload: EncodeCallMsgPayload): CallMsgJson => { + const encodeField = (fieldName: string, value: string): string => { + try { + return hexToBase64(value) + } catch (e) { + throw new Error( + `Invalid hex in '${fieldName}' field of CallMsg: ${e instanceof Error ? e.message : String(e)}`, + ) + } + } + + return { + from: encodeField('from', payload.from), + to: encodeField('to', payload.to), + data: encodeField('data', payload.data), + } +} /** * Default values expected by the EVM capability for report encoding. @@ -148,5 +164,95 @@ export const prepareReportRequest = ( ...reportEncoder, }) +/** + * Validates a hex string and checks that the decoded bytes have the expected length. + */ +const validateHexByteLength = (hex: string, expectedBytes: number, fieldLabel: string): string => { + const bytes = hexToBytes(hex) + if (bytes.length !== expectedBytes) { + throw new Error( + `Invalid ${fieldLabel}: expected ${expectedBytes} bytes, got ${bytes.length} bytes from '${hex.length > 200 ? hex.slice(0, 200) + '...' : hex}'. EVM ${fieldLabel}s must be exactly ${expectedBytes} bytes.`, + ) + } + return hexToBase64(hex) +} + +export interface LogTriggerConfigOptions { + /** EVM addresses to monitor — hex strings with 0x prefix (20 bytes each) */ + addresses: Hex[] + /** Topic filters — array of up to 4 arrays of hex topic values (32 bytes each). + * - topics[0]: event signatures (keccak256 hashes), at least one required + * - topics[1]: possible values for first indexed arg (optional) + * - topics[2]: possible values for second indexed arg (optional) + * - topics[3]: possible values for third indexed arg (optional) + */ + topics?: Hex[][] + /** Confidence level for log finality. Defaults to SAFE. */ + confidence?: 'SAFE' | 'LATEST' | 'FINALIZED' +} + +/** + * Creates a log trigger configuration from hex-encoded addresses and topics. + * + * This helper converts hex addresses and topic hashes to the base64-encoded format + * expected by the EVM capability's `FilterLogTriggerRequest`, and validates that + * addresses are 20 bytes and topics are 32 bytes. + * + * @example + * const WETH = '0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9' + * const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' + * + * handler( + * evmClient.logTrigger(logTriggerConfig({ + * addresses: [WETH], + * topics: [[TRANSFER]], + * confidence: 'LATEST', + * })), + * onLogTrigger, + * ) + * + * @param opts - Hex-encoded addresses, topic filters, and optional confidence level. + * @returns The `FilterLogTriggerRequestJson` ready to pass to `evmClient.logTrigger()`. + */ +export const logTriggerConfig = (opts: LogTriggerConfigOptions): FilterLogTriggerRequestJson => { + if (!opts.addresses || opts.addresses.length === 0) { + throw new Error( + 'logTriggerConfig requires at least one address. Provide an array of hex-encoded EVM addresses (20 bytes each).', + ) + } + + const addresses = opts.addresses.map((addr, i) => { + try { + return validateHexByteLength(addr, 20, 'address') + } catch (e) { + throw new Error( + `Invalid address at index ${i}: ${e instanceof Error ? e.message : String(e)}`, + ) + } + }) + + const topics = opts.topics?.map((topicSlot, slotIndex) => ({ + values: topicSlot.map((topic, valueIndex) => { + try { + return validateHexByteLength(topic, 32, 'topic') + } catch (e) { + throw new Error( + `Invalid topic at topics[${slotIndex}][${valueIndex}]: ${e instanceof Error ? e.message : String(e)}`, + ) + } + }), + })) + + const confidence: ConfidenceLevelJson | undefined = opts.confidence + ? `CONFIDENCE_LEVEL_${opts.confidence}` + : undefined + + return { + addresses, + ...(topics ? { topics } : {}), + ...(confidence ? { confidence } : {}), + } +} + export const isChainSelectorSupported = (chainSelectorName: string) => Object.keys(EVMClient.SUPPORTED_CHAIN_SELECTORS).includes(chainSelectorName)