From 88b1fa3994617797ad50c8a690d711905cc80cf2 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:39:08 -0400 Subject: [PATCH 1/6] fixes issues with `v` --- .../unit/__snapshots__/decoders.test.ts.snap | 2 +- src/genericSigning.ts | 28 +++++++++---------- src/util.ts | 8 +++++- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/__test__/unit/__snapshots__/decoders.test.ts.snap b/src/__test__/unit/__snapshots__/decoders.test.ts.snap index b47d0898..be63da9d 100644 --- a/src/__test__/unit/__snapshots__/decoders.test.ts.snap +++ b/src/__test__/unit/__snapshots__/decoders.test.ts.snap @@ -3609,7 +3609,7 @@ exports[`decoders > sign - generic 1`] = ` "sig": { "r": "0x640b2c690858ab8d0b9500f9ed64c9aa6b7467b77f1199b061aa96ea780aadaa", "s": "0x48f830f9290dd1b3eaf1922e08a8c992873be1162bd6d5bef681cf911328abe5", - "v": "0x1", + "v": 1n, }, } `; diff --git a/src/genericSigning.ts b/src/genericSigning.ts index 180516f2..013ed79c 100644 --- a/src/genericSigning.ts +++ b/src/genericSigning.ts @@ -232,21 +232,21 @@ export const parseGenericSigningResponse = function (res, off, req) { off += 65; } // Handle `GpECDSASig_t` - parsed.sig = parseDER(res.slice(off, off + 2 + res[off + 1])); + const derSig = parseDER(res.slice(off, off + 2 + res[off + 1])); // Remove any leading zeros in signature components to ensure // the result is a 64 byte sig - parsed.sig.r = fixLen(parsed.sig.r, 32); - parsed.sig.s = fixLen(parsed.sig.s, 32); - - // If this is an EVM request, we want to add a `v` and format r,s as hex strings with 0x prefix - if (req.encodingType === Constants.SIGNING.ENCODINGS.EVM) { + const rBuf = fixLen(derSig.r, 32); + const sBuf = fixLen(derSig.s, 32); + + parsed.sig = { + r: `0x${rBuf.toString('hex')}`, + s: `0x${sBuf.toString('hex')}` + }; + + if (req.encodingType === Constants.SIGNING.ENCODINGS.EVM || + req.hashType === Constants.SIGNING.HASHES.KECCAK256) { const vBn = getV(req.origPayloadBuf, parsed); - // Convert v to hex string for consistency with r and s - parsed.sig.v = `0x${vBn.toString(16)}`; - - // Format r and s as hex strings with 0x prefix for consistency with legacy ETH signing - parsed.sig.r = `0x${parsed.sig.r.toString('hex')}`; - parsed.sig.s = `0x${parsed.sig.s.toString('hex')}`; + parsed.sig.v = BigInt(vBn.toString()); } } else if (req.curveType === Constants.SIGNING.CURVES.ED25519) { if (!req.omitPubkey) { @@ -257,8 +257,8 @@ export const parseGenericSigningResponse = function (res, off, req) { off += 32; // Handle `GpEdDSASig_t` parsed.sig = { - r: res.slice(off, off + 32), - s: res.slice(off + 32, off + 64), + r: `0x${res.slice(off, off + 32).toString('hex')}`, + s: `0x${res.slice(off + 32, off + 64).toString('hex')}`, }; } else if (req.curveType === Constants.SIGNING.CURVES.BLS12_381_G2) { if (!req.omitPubkey) { diff --git a/src/util.ts b/src/util.ts index 79ab2412..38db4266 100644 --- a/src/util.ts +++ b/src/util.ts @@ -759,7 +759,13 @@ export const getV = function (tx: any, resp: any) { chainId = tx.common.chainIdBN().toNumber(); } } - const rs = new Uint8Array(Buffer.concat([resp.sig.r, resp.sig.s])); + const rBuf = Buffer.isBuffer(resp.sig.r) + ? resp.sig.r + : Buffer.from(resp.sig.r.slice(2), 'hex'); + const sBuf = Buffer.isBuffer(resp.sig.s) + ? resp.sig.s + : Buffer.from(resp.sig.s.slice(2), 'hex'); + const rs = new Uint8Array(Buffer.concat([rBuf, sBuf])); const pubkey = new Uint8Array(resp.pubkey); const recovery0 = ecdsaRecover(rs, 0, hash, false); const recovery1 = ecdsaRecover(rs, 1, hash, false); From 04528c9e71cc524d9b6f2eef5bbe1831be7d014b Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Thu, 28 Aug 2025 18:36:37 -0400 Subject: [PATCH 2/6] fix lint --- src/genericSigning.ts | 12 +++++++----- src/util.ts | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/genericSigning.ts b/src/genericSigning.ts index 013ed79c..2f74ce40 100644 --- a/src/genericSigning.ts +++ b/src/genericSigning.ts @@ -237,14 +237,16 @@ export const parseGenericSigningResponse = function (res, off, req) { // the result is a 64 byte sig const rBuf = fixLen(derSig.r, 32); const sBuf = fixLen(derSig.s, 32); - + parsed.sig = { r: `0x${rBuf.toString('hex')}`, - s: `0x${sBuf.toString('hex')}` + s: `0x${sBuf.toString('hex')}`, }; - - if (req.encodingType === Constants.SIGNING.ENCODINGS.EVM || - req.hashType === Constants.SIGNING.HASHES.KECCAK256) { + + if ( + req.encodingType === Constants.SIGNING.ENCODINGS.EVM || + req.hashType === Constants.SIGNING.HASHES.KECCAK256 + ) { const vBn = getV(req.origPayloadBuf, parsed); parsed.sig.v = BigInt(vBn.toString()); } diff --git a/src/util.ts b/src/util.ts index 38db4266..2a524c6c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -759,8 +759,8 @@ export const getV = function (tx: any, resp: any) { chainId = tx.common.chainIdBN().toNumber(); } } - const rBuf = Buffer.isBuffer(resp.sig.r) - ? resp.sig.r + const rBuf = Buffer.isBuffer(resp.sig.r) + ? resp.sig.r : Buffer.from(resp.sig.r.slice(2), 'hex'); const sBuf = Buffer.isBuffer(resp.sig.s) ? resp.sig.s From 985dd1b7bf5ebfc7c0c5cd9e25b33be547c453d3 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:03:46 -0400 Subject: [PATCH 3/6] fix: improve v parameter handling and transaction validation - Add comprehensive transaction schema validation using Zod - Fix v parameter calculation for different transaction types - Consolidate signature recovery logic into unified functions - Add transaction parsing utilities for better error handling - Improve test coverage for signature utils and validators - Fix data field encoding in test builders - Update encoder test snapshots to reflect corrected behavior This addresses issues with v parameter calculation across different transaction types (legacy, EIP-155, EIP-1559, EIP-2930, EIP-7702) and improves the overall robustness of transaction handling. --- package.json | 3 +- pnpm-lock.yaml | 28 +-- src/__test__/e2e/signing/evm-tx.test.ts | 8 +- .../unit/parseGenericSigningResponse.test.ts | 173 ++++++++++++++ ...YParity.test.ts => signatureUtils.test.ts} | 86 ++++++- src/__test__/unit/validators.test.ts | 160 +++++++++++++ src/__test__/utils/builders.ts | 2 +- src/calldata/evm.ts | 8 +- src/ethereum.ts | 204 ++++++++--------- src/functions/sign.ts | 9 +- src/genericSigning.ts | 55 ++++- src/schemas/index.ts | 1 + src/schemas/transaction.ts | 212 ++++++++++++++++++ src/shared/functions.ts | 2 +- src/util.ts | 192 +++++++++++----- src/utils/transaction-parsing.ts | 163 ++++++++++++++ 16 files changed, 1110 insertions(+), 196 deletions(-) create mode 100644 src/__test__/unit/parseGenericSigningResponse.test.ts rename src/__test__/unit/{getYParity.test.ts => signatureUtils.test.ts} (79%) create mode 100644 src/schemas/index.ts create mode 100644 src/schemas/transaction.ts create mode 100644 src/utils/transaction-parsing.ts diff --git a/package.json b/package.json index bc0b48e8..c6ac9e02 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "ox": "^0.8.1", "secp256k1": "5.0.1", "uuid": "^10.0.0", - "viem": "^2.31.4" + "viem": "^2.31.4", + "zod": "^3.23.8" }, "devDependencies": { "@chainsafe/bls-keystore": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f28450e..5c194c7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 4.17.21 ox: specifier: ^0.8.1 - version: 0.8.1(typescript@5.8.3)(zod@3.24.3) + version: 0.8.1(typescript@5.8.3)(zod@4.1.5) secp256k1: specifier: 5.0.1 version: 5.0.1 @@ -73,7 +73,10 @@ importers: version: 10.0.0 viem: specifier: ^2.31.4 - version: 2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.3) + version: 2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.5) + zod: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: '@chainsafe/bls-keystore': specifier: ^3.1.0 @@ -3107,8 +3110,8 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} - zod@3.24.3: - resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} + zod@4.1.5: + resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} snapshots: @@ -4152,10 +4155,10 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - abitype@1.0.8(typescript@5.8.3)(zod@3.24.3): + abitype@1.0.8(typescript@5.8.3)(zod@4.1.5): optionalDependencies: typescript: 5.8.3 - zod: 3.24.3 + zod: 4.1.5 acorn-jsx@5.3.2(acorn@8.13.0): dependencies: @@ -5347,7 +5350,7 @@ snapshots: outvariant@1.4.3: {} - ox@0.8.1(typescript@5.8.3)(zod@3.24.3): + ox@0.8.1(typescript@5.8.3)(zod@4.1.5): dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/ciphers': 1.3.0 @@ -5355,7 +5358,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@3.24.3) + abitype: 1.0.8(typescript@5.8.3)(zod@4.1.5) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -5866,15 +5869,15 @@ snapshots: dependencies: safe-buffer: 5.2.1 - viem@2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.3): + viem@2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.5): dependencies: '@noble/curves': 1.9.2 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@3.24.3) + abitype: 1.0.8(typescript@5.8.3)(zod@4.1.5) isows: 1.0.7(ws@8.18.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - ox: 0.8.1(typescript@5.8.3)(zod@3.24.3) + ox: 0.8.1(typescript@5.8.3)(zod@4.1.5) ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -6059,5 +6062,4 @@ snapshots: yoctocolors-cjs@2.1.2: {} - zod@3.24.3: - optional: true + zod@4.1.5: {} diff --git a/src/__test__/e2e/signing/evm-tx.test.ts b/src/__test__/e2e/signing/evm-tx.test.ts index 3995e482..74e7ef3a 100644 --- a/src/__test__/e2e/signing/evm-tx.test.ts +++ b/src/__test__/e2e/signing/evm-tx.test.ts @@ -21,7 +21,7 @@ describe('EVM Transaction Signing - Unified Test Suite', () => { await setupClient(); }); - describe.skip('Legacy Transactions', () => { + describe('Legacy Transactions', () => { LEGACY_VECTORS.forEach((vector, index) => { it(`${vector.name} (${index + 1}/${LEGACY_VECTORS.length})`, async () => { await signAndCompareTransaction(vector.tx, vector.name); @@ -29,7 +29,7 @@ describe('EVM Transaction Signing - Unified Test Suite', () => { }); }); - describe.skip('EIP-1559 Transactions (Fee Market)', () => { + describe('EIP-1559 Transactions (Fee Market)', () => { EIP1559_TEST_VECTORS.forEach((vector, index) => { it(`${vector.name} (${index + 1}/${EIP1559_TEST_VECTORS.length})`, async () => { await signAndCompareTransaction(vector.tx, vector.name); @@ -37,7 +37,7 @@ describe('EVM Transaction Signing - Unified Test Suite', () => { }); }); - describe.skip('EIP-2930 Transactions (Access Lists)', () => { + describe('EIP-2930 Transactions (Access Lists)', () => { EIP2930_TEST_VECTORS.forEach((vector, index) => { it(`${vector.name} (${index + 1}/${EIP2930_TEST_VECTORS.length})`, async () => { await signAndCompareTransaction(vector.tx, vector.name); @@ -53,7 +53,7 @@ describe('EVM Transaction Signing - Unified Test Suite', () => { }); }); - describe.skip('Edge Cases & Boundary Conditions', () => { + describe('Edge Cases & Boundary Conditions', () => { EDGE_CASE_TEST_VECTORS.forEach((vector, index) => { it(`${vector.name} (${index + 1}/${EDGE_CASE_TEST_VECTORS.length})`, async () => { await signAndCompareTransaction(vector.tx, vector.name); diff --git a/src/__test__/unit/parseGenericSigningResponse.test.ts b/src/__test__/unit/parseGenericSigningResponse.test.ts new file mode 100644 index 00000000..f5be16ca --- /dev/null +++ b/src/__test__/unit/parseGenericSigningResponse.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { Buffer } from 'buffer'; +import { parseGenericSigningResponse } from '../../genericSigning'; +import { Constants } from '../../index'; +import secp256k1 from 'secp256k1'; +import { Hash } from 'ox'; + +describe('parseGenericSigningResponse', () => { + // Helper to create a DER signature + const createDERSignature = (r: Buffer, s: Buffer): Buffer => { + const rLen = r.length; + const sLen = s.length; + const totalLen = 4 + rLen + sLen; + const sig = Buffer.alloc(totalLen + 2); + + sig[0] = 0x30; // DER sequence + sig[1] = totalLen; + sig[2] = 0x02; // Integer type + sig[3] = rLen; + r.copy(sig, 4); + sig[4 + rLen] = 0x02; // Integer type + sig[4 + rLen + 1] = sLen; + s.copy(sig, 4 + rLen + 2); + + // Pad to 74 bytes (standard for Lattice) + const padded = Buffer.alloc(74); + sig.copy(padded, 0); + return padded; + }; + + it('should handle generic KECCAK256 message (not EVM transaction)', () => { + // Simulate signing a plain text message "Test!" + const payload = Buffer.from('Test!'); + const hash = Buffer.from(Hash.keccak256(payload)); + + // Create a fake signature + const privateKey = Buffer.from( + '0101010101010101010101010101010101010101010101010101010101010101', + 'hex', + ); + const sigObj = secp256k1.ecdsaSign(hash, privateKey); + const publicKey = secp256k1.publicKeyCreate(privateKey, false); + + // Create DER-encoded signature response + const derSig = createDERSignature( + Buffer.from(sigObj.signature.slice(0, 32)), + Buffer.from(sigObj.signature.slice(32, 64)), + ); + + // Create mock response buffer + const mockResponse = Buffer.concat([ + Buffer.from([0x04]), // Uncompressed pubkey prefix + publicKey.slice(1), // Remove compression prefix from secp256k1 output (64 bytes) + derSig, + ]); + + const req = { + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.KECCAK256, + encodingType: null, // Not EVM encoding + origPayloadBuf: payload, + }; + + const result = parseGenericSigningResponse(mockResponse, 0, req); + + expect(result).toBeDefined(); + expect(result.sig).toBeDefined(); + expect(result.sig.v).toBeDefined(); + expect(typeof result.sig.v).toBe('bigint'); + + // For non-EVM generic messages, v should be 27 or 28 + const vNumber = Number(result.sig.v); + expect([27n, 28n]).toContain(result.sig.v); + }); + + it('should handle EVM transaction encoding', () => { + // Simulate an unsigned legacy transaction + const unsignedTx = Buffer.from( + 'e9808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080', + 'hex', + ); + const hash = Buffer.from(Hash.keccak256(unsignedTx)); + + // Create a fake signature + const privateKey = Buffer.from( + '0101010101010101010101010101010101010101010101010101010101010101', + 'hex', + ); + const sigObj = secp256k1.ecdsaSign(hash, privateKey); + const publicKey = secp256k1.publicKeyCreate(privateKey, false); + + // Create DER-encoded signature response + const derSig = createDERSignature( + Buffer.from(sigObj.signature.slice(0, 32)), + Buffer.from(sigObj.signature.slice(32, 64)), + ); + + // Create mock response buffer + const mockResponse = Buffer.concat([ + Buffer.from([0x04]), // Uncompressed pubkey prefix + publicKey.slice(1), // Remove compression prefix from secp256k1 output (64 bytes) + derSig, + ]); + + const req = { + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.KECCAK256, + encodingType: Constants.SIGNING.ENCODINGS.EVM, + origPayloadBuf: unsignedTx, + }; + + const result = parseGenericSigningResponse(mockResponse, 0, req); + + expect(result).toBeDefined(); + expect(result.sig).toBeDefined(); + expect(result.sig.v).toBeDefined(); + expect(typeof result.sig.v).toBe('bigint'); + + // For pre-EIP155 transactions, v should be 27 or 28 + const vNumber = Number(result.sig.v); + expect([27, 28]).toContain(vNumber); + }); + + it('should handle RLP-encoded data that looks like a transaction', () => { + // Create an RLP-encoded array with 6+ elements (looks like a transaction) + const RLP = require('@ethereumjs/rlp').RLP; + const txLikeData = [ + Buffer.from([0x01]), // nonce + Buffer.from([0x02]), // gasPrice + Buffer.from([0x03]), // gasLimit + Buffer.from([0x04]), // to + Buffer.from([0x05]), // value + Buffer.from([0x06]), // data + ]; + const rlpEncoded = Buffer.from(RLP.encode(txLikeData)); + const hash = Buffer.from(Hash.keccak256(rlpEncoded)); + + // Create a fake signature + const privateKey = Buffer.from( + '0101010101010101010101010101010101010101010101010101010101010101', + 'hex', + ); + const sigObj = secp256k1.ecdsaSign(hash, privateKey); + const publicKey = secp256k1.publicKeyCreate(privateKey, false); + + // Create DER-encoded signature response + const derSig = createDERSignature( + Buffer.from(sigObj.signature.slice(0, 32)), + Buffer.from(sigObj.signature.slice(32, 64)), + ); + + // Create mock response buffer + const mockResponse = Buffer.concat([ + Buffer.from([0x04]), // Uncompressed pubkey prefix + publicKey.slice(1), // Remove compression prefix from secp256k1 output (64 bytes) + derSig, + ]); + + const req = { + curveType: Constants.SIGNING.CURVES.SECP256K1, + hashType: Constants.SIGNING.HASHES.KECCAK256, + encodingType: null, // Not explicitly EVM + origPayloadBuf: rlpEncoded, + }; + + const result = parseGenericSigningResponse(mockResponse, 0, req); + + expect(result).toBeDefined(); + expect(result.sig).toBeDefined(); + expect(result.sig.v).toBeDefined(); + expect(typeof result.sig.v).toBe('bigint'); + }); +}); diff --git a/src/__test__/unit/getYParity.test.ts b/src/__test__/unit/signatureUtils.test.ts similarity index 79% rename from src/__test__/unit/getYParity.test.ts rename to src/__test__/unit/signatureUtils.test.ts index d571ae0b..0cbf5a8f 100644 --- a/src/__test__/unit/getYParity.test.ts +++ b/src/__test__/unit/signatureUtils.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Buffer } from 'buffer'; import { Hash } from 'ox'; -import { getYParity, randomBytes } from '../../util'; +import { getYParity, getV, randomBytes } from '../../util'; import secp256k1 from 'secp256k1'; describe('getYParity', () => { @@ -365,3 +365,87 @@ describe('getYParity', () => { }); }); }); + +describe('getV function', () => { + // Helper to create a valid signature + const createValidSignature = (messageHash: Buffer, privateKey?: Buffer) => { + // Use deterministic key if not provided + const privKey = + privateKey || + Buffer.from( + '0101010101010101010101010101010101010101010101010101010101010101', + 'hex', + ); + + const sigObj = secp256k1.ecdsaSign(messageHash, privKey); + const publicKey = secp256k1.publicKeyCreate(privKey, false); + + return { + sig: { + r: Buffer.from(sigObj.signature.slice(0, 32)), + s: Buffer.from(sigObj.signature.slice(32, 64)), + }, + pubkey: Buffer.from(publicKey), + recovery: sigObj.recid, + }; + }; + + it('should handle unsigned legacy transaction with valid signature', () => { + // A simple unsigned legacy transaction + const unsignedTxRLP = Buffer.from( + 'e9808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080', + 'hex', + ); + + // Hash the transaction + const hash = Buffer.from(Hash.keccak256(unsignedTxRLP)); + + // Create a valid signature for this hash + const resp = createValidSignature(hash); + + // Should return correct v value (27 or 28 for non-EIP155) + const v = getV(unsignedTxRLP, resp); + expect(v.toNumber()).toBe(27 + resp.recovery); + }); + + it('should parse viem parseTransaction correctly', () => { + // Test that we're using viem's parseTransaction correctly + // This is a signed legacy transaction + const signedTx = + '0xf86c0a8504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a0134f5038e0e6a96741e17a82c8df13e9dc10c3b0e9e956cf7dcf21e1e3b73f9fa0638cf1b1f9dd5e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8'; + + const mockResp = { + sig: { + r: Buffer.from( + '134f5038e0e6a96741e17a82c8df13e9dc10c3b0e9e956cf7dcf21e1e3b73f9f', + 'hex', + ), + s: Buffer.from( + '638cf1b1f9dd5e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8', + 'hex', + ), + }, + // This is a fake pubkey, so recovery will fail + pubkey: Buffer.from('04' + '1'.repeat(128), 'hex'), + }; + + // Should throw because pubkey doesn't match + expect(() => getV(signedTx, mockResp)).toThrow(); + }); + + it('should handle hex string input', () => { + const txHex = + '0xe9808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080'; + + const mockResp = { + sig: { + r: '0x' + '1'.repeat(64), // 32 bytes as hex string + s: '0x' + '2'.repeat(64), // 32 bytes as hex string + }, + pubkey: Buffer.from('04' + '1'.repeat(128), 'hex'), + }; + + // Should throw because signature doesn't match + expect(() => getV(txHex, mockResp)).toThrow(); + }); +}); diff --git a/src/__test__/unit/validators.test.ts b/src/__test__/unit/validators.test.ts index 0f27466a..9f0b6522 100644 --- a/src/__test__/unit/validators.test.ts +++ b/src/__test__/unit/validators.test.ts @@ -9,6 +9,7 @@ import { isValid4ByteResponse, isValidBlockExplorerResponse, } from '../../shared/validators'; +import { normalizeToViemTransaction } from '../../ethereum'; import { buildGetAddressesObject, buildValidateConnectObject, @@ -141,4 +142,163 @@ describe('validators', () => { }); }); }); + + describe('transaction validation', () => { + describe('EIP-7702 transactions', () => { + test('rejects missing fee fields', () => { + const tx = { + to: '0x' + '1'.repeat(40), + value: '1000000000000000000', + chainId: 1, + authorizationList: [ + { chainId: 1, address: '0x' + '2'.repeat(40), nonce: 0 }, + ], + gasPrice: '15000000000', + }; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + }); + + describe('negative values', () => { + test('rejects negative value', () => { + const tx = { + to: '0x' + '1'.repeat(40), + value: -100, + gasPrice: '10000000000', + }; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + + test('rejects negative nonce', () => { + const tx = { + to: '0x' + '1'.repeat(40), + value: '100', + gasPrice: '10000000000', + nonce: -1, + }; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + + test('rejects negative gas price', () => { + const tx = { + to: '0x' + '1'.repeat(40), + value: '100', + gasPrice: -10, + }; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + }); + + describe('invalid data types', () => { + test('rejects boolean data field', () => { + const tx = { + to: '0x1234567890123456789012345678901234567890', + value: true, + chainId: '0x1', + gasPrice: NaN, + nonce: null, + data: false, + }; + + expect(() => normalizeToViemTransaction(tx as any)).toThrow(); + }); + }); + + describe('authorization list validation', () => { + test('rejects invalid authorization data', () => { + const tx = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', + chainId: 1, + maxFeePerGas: '20000000000', + maxPriorityFeePerGas: '2000000000', + authorizationList: [ + { + chainId: 'not-a-number', + address: '0x123', + nonce: undefined, + }, + ], + }; + + expect(() => normalizeToViemTransaction(tx as any)).toThrow(); + }); + }); + + describe('circular references', () => { + test('rejects circular references', () => { + const tx: any = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', + chainId: 1, + maxFeePerGas: '20000000000', + maxPriorityFeePerGas: '2000000000', + }; + + tx.self = tx; + tx.authorizationList = [tx]; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + }); + + describe('gas field handling', () => { + test('gasLimit takes precedence over gas', () => { + const tx = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', + chainId: 1, + gasPrice: '15000000000', + gas: '50000', + gasLimit: '21000', + }; + + const result = normalizeToViemTransaction(tx); + expect(result.gas).toBe(21000n); + }); + + test('accepts zero gas values', () => { + const tx = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', + chainId: 1, + gasPrice: '0', + gasLimit: '0', + }; + + const result = normalizeToViemTransaction(tx); + expect(result.gas).toBe(0n); + expect(result.type).toBe('legacy'); + expect((result as any).gasPrice).toBe(0n); + }); + }); + + describe('chainId validation', () => { + test('rejects zero chainId', () => { + const tx = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', + chainId: 0, + gasPrice: '15000000000', + }; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + + test('rejects non-integer chainId', () => { + const tx = { + to: '0x1234567890123456789012345678901234567890', + value: '1000000000000000000', + chainId: 1.5, + gasPrice: '15000000000', + }; + + expect(() => normalizeToViemTransaction(tx)).toThrow(); + }); + }); + }); }); diff --git a/src/__test__/utils/builders.ts b/src/__test__/utils/builders.ts index aa62ce27..cf36419d 100644 --- a/src/__test__/utils/builders.ts +++ b/src/__test__/utils/builders.ts @@ -105,7 +105,7 @@ export const buildSignObject = (fwVersion, overrides?) => { to: '0xc0c8f96C2fE011cc96770D2e37CfbfeAFB585F0e', from: '0xc0c8f96C2fE011cc96770D2e37CfbfeAFB585F0e', value: 0x80000000, - data: 0x0, + data: '0x0', signerPath: [0x80000000 + 44, 0x80000000 + 60, 0x80000000, 0, 0], nonce: 0x80000000, gasLimit: 0x80000000, diff --git a/src/calldata/evm.ts b/src/calldata/evm.ts index 25a26e1a..59a7e48b 100644 --- a/src/calldata/evm.ts +++ b/src/calldata/evm.ts @@ -125,10 +125,14 @@ export const getNestedCalldata = function (def, calldata) { } } else if (isBytesItem(defParams[i])) { // Regular `bytes` type - perform size check - if (typeof paramData !== 'string' || !paramData.startsWith('0x')) { + if ( + typeof paramData !== 'string' || + !(paramData as string).startsWith('0x') + ) { nestedDefIsPossible = false; } else { - const paramDataBuf = Buffer.from(paramData.slice(2), 'hex'); + const data = paramData as string; + const paramDataBuf = Buffer.from(data.slice(2), 'hex'); nestedDefIsPossible = couldBeNestedDef(paramDataBuf); } } else { diff --git a/src/ethereum.ts b/src/ethereum.ts index 892ed10d..0ecddafc 100644 --- a/src/ethereum.ts +++ b/src/ethereum.ts @@ -1,7 +1,5 @@ // Utils for Ethereum transactions. This is effecitvely a shim of ethereumjs-util, which // does not have browser (or, by proxy, React-Native) support. -import { Chain, Common, Hardfork } from '@ethereumjs/common'; -import { TransactionFactory } from '@ethereumjs/tx'; import BN from 'bignumber.js'; import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util'; import { Hash } from 'ox'; @@ -12,6 +10,7 @@ import { HANDLE_LARGER_CHAIN_ID, MAX_CHAIN_ID_BYTES, ethMsgProtocol, + EXTERNAL, } from './constants'; import { LatticeSignSchema } from './protocol'; import { @@ -20,16 +19,24 @@ import { fixLen, isAsciiStr, splitFrames, + convertRecoveryToV, } from './util'; import * as cbor from 'cbor'; import bdec from 'cbor-bigdecimal'; import { - Hex, TransactionSerializable, serializeTransaction, + type Hex, hexToNumber, } from 'viem'; -import { TransactionRequest, TRANSACTION_TYPE } from './types'; +import { + type SigningPath, + type FirmwareConstants, + TransactionRequest, + TRANSACTION_TYPE, +} from './types'; +import { buildGenericSigningMsgRequest } from './genericSigning'; +import { TransactionSchema, type FlexibleTransaction } from './schemas'; bdec(cbor); @@ -567,27 +574,18 @@ function pubToAddrStr(pub) { // Convert a 0/1 `v` into a recovery param: // * For non-EIP155 transactions, return `27 + v` // * For EIP155 transactions, return `(CHAIN_ID*2) + 35 + v` +// Uses the consolidated convertRecoveryToV function from util.ts function getRecoveryParam(v, txData: any = {}) { - const { chainId, useEIP155, type } = txData; - // For EIP1559 and EIP2930 transactions, we want the recoveryParam (0 or 1) - // rather than the `v` value because the `chainId` is already included in the - // transaction payload. - if (type === 1 || type === 2) { - return ensureHexBuffer(v, true); // 0 or 1, with 0 expected as an empty buffer - } else if (!useEIP155 || !chainId) { - // For ETH messages and non-EIP155 chains the set should be [27, 28] for `v` - return Buffer.from(new BN(v).plus(27).toString(16), 'hex'); - } + const result = convertRecoveryToV(v, txData); - // We will use EIP155 in most cases. Convert v to a bignum and operate on it. - // Note that the protocol calls for v = (CHAIN_ID*2) + 35/36, where 35 or 36 - // is decided on based on the ecrecover result. `v` is passed in as either 0 or 1 - // so we add 35 to that. - const chainIdBuf = getChainIdBuf(chainId); - const chainIdBN = new BN(chainIdBuf.toString('hex'), 16); - return ensureHexBuffer( - `0x${chainIdBN.times(2).plus(35).plus(v).toString(16)}`, - ); + // convertRecoveryToV returns Buffer for typed transactions, BN for legacy + // Always return Buffer to maintain compatibility with existing code + if (Buffer.isBuffer(result)) { + return result; + } else { + // Convert BN result to hex buffer + return ensureHexBuffer(`0x${result.toString(16)}`); + } } const chainIds = { @@ -1025,98 +1023,83 @@ function get_rlp_encoded_preimage(rawTx, txType) { } } -// ====== -// TEMPORARY BRIDGE -// We are migrating from all legacy signing paths to a single generic -// signing route. If users are attempting a legacy transaction request -// against a Lattice on firmware v0.15.0 and above, we need to convert -// that to a generic signing request. -// -// NOTE: Once we deprecate, we will remove this entire file -// ====== -const ethConvertLegacyToGenericReq = function (req) { - let common; - if (!req.chainId || ensureHexBuffer(req.chainId).toString('hex') === '01') { - common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.London }); - } else { - // Not every network will support these EIPs but we will allow - // signing of transactions using them - common = Common.custom( - { chainId: Number(req.chainId) }, - { hardfork: Hardfork.London, eips: [1559, 2930] }, - ); - } - const tx = TransactionFactory.fromTxData(req, { common }); - // Get the raw transaction payload to be hashed and signed. - // Different `@ethereumjs/tx` Transaction object types have - // slightly different APIs around this. - if (req.type) { - // Newer transaction types - return tx.getMessageToSign(); - } else { - // Legacy transaction type - return Buffer.from(RLP.encode(tx.getMessageToSign())); - } -}; - -// Convert an ethers `TransactionRequest` to a viem `TransactionSerializable` -export const toViemTransaction = ( - tx: TransactionRequest, +/** + * Normalizes a flexible transaction input object into a `viem`-compatible + * `TransactionSerializable` object. It uses a comprehensive `zod` schema + * to validate, parse, and transform various input formats into a consistent, + * secure, and well-typed structure. This function serves as the single entry + * point for handling all EVM transaction types. + * + * @param tx - A flexible transaction object. Can be a legacy, EIP-1559, + * EIP-2930, or EIP-7702 transaction with fields in various formats (e.g., + * hex strings, numbers, bigints). + * @returns A `viem`-compatible `TransactionSerializable` object. + */ +export const normalizeToViemTransaction = ( + tx: unknown, ): TransactionSerializable => { - const base = { - to: tx.to as `0x${string}`, - value: tx.value ? BigInt(tx.value) : undefined, - data: tx.data as `0x${string}`, - nonce: tx.nonce, - gas: tx.gasLimit ? BigInt(tx.gasLimit) : undefined, - chainId: tx.chainId, + const parsed = TransactionSchema.parse(tx); + + return { + ...parsed, + to: parsed.to as Hex, + data: parsed.data as Hex, + gas: parsed.gas, + value: parsed.value, + nonce: parsed.nonce, + chainId: parsed.chainId, + gasPrice: 'gasPrice' in parsed ? parsed.gasPrice : undefined, + maxFeePerGas: 'maxFeePerGas' in parsed ? parsed.maxFeePerGas : undefined, + maxPriorityFeePerGas: + 'maxPriorityFeePerGas' in parsed + ? parsed.maxPriorityFeePerGas + : undefined, + accessList: 'accessList' in parsed ? parsed.accessList : undefined, + authorizationList: + 'authorizationList' in parsed ? parsed.authorizationList : undefined, }; +}; - switch (tx.type) { - case TRANSACTION_TYPE.LEGACY: - return { - ...base, - type: 'legacy', - gasPrice: BigInt(tx.gasPrice), - }; +/** + * Convert Ethereum transaction to serialized bytes for generic signing. + * Bridge function for firmware v0.15.0+ which removed legacy ETH signing paths. + */ +const convertEthereumTransactionToGenericRequest = function ( + req: FlexibleTransaction, +) { + // Use the unified normalization and serialization pipeline. + // 1. Normalize the potentially varied input to a standard viem format. + const viemTx = normalizeToViemTransaction(req); + // 2. Serialize the transaction to RLP-encoded bytes. + const serializedTx = serializeTransaction(viemTx); + return Buffer.from(serializedTx.slice(2), 'hex'); +}; - case TRANSACTION_TYPE.EIP2930: - return { - ...base, - type: 'eip2930', - gasPrice: BigInt(tx.gasPrice), - accessList: tx.accessList || [], - }; +// Type for Ethereum generic signing request +type EthereumGenericSigningRequestParams = FlexibleTransaction & { + fwConstants: FirmwareConstants; + signerPath: SigningPath; +}; - case TRANSACTION_TYPE.EIP1559: - return { - ...base, - type: 'eip1559', - maxFeePerGas: BigInt(tx.maxFeePerGas), - maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas), - accessList: tx.accessList || [], - }; +/** + * Build complete generic signing request for Ethereum transactions. + * One-step function combining transaction conversion and generic signing setup. + */ +export const buildEthereumGenericSigningRequest = function ( + req: EthereumGenericSigningRequestParams, +) { + const { fwConstants, signerPath, ...txData } = req; - case TRANSACTION_TYPE.EIP7702_AUTH_LIST: - return { - ...base, - type: 'eip7702', - maxFeePerGas: BigInt(tx.maxFeePerGas), - maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas), - accessList: tx.accessList || [], - authorizationList: tx.authorizationList.map((auth) => ({ - chainId: auth.chainId, - address: auth.address, - nonce: auth.nonce, - r: auth.r, - s: auth.s, - yParity: auth.yParity || 0, - })), - }; + const payload = convertEthereumTransactionToGenericRequest(txData); - default: - throw new Error(`Unsupported transaction type: ${(tx as any).type}`); - } + return buildGenericSigningMsgRequest({ + fwConstants, + encodingType: EXTERNAL.SIGNING.ENCODINGS.EVM, + curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, + hashType: EXTERNAL.SIGNING.HASHES.KECCAK256, + signerPath, + payload, + }); }; /** @@ -1284,6 +1267,7 @@ export default { hashTransaction, chainIds, ensureHexBuffer, - - ethConvertLegacyToGenericReq, + normalizeToViemTransaction, + convertEthereumTransactionToGenericRequest, + buildEthereumGenericSigningRequest, }; diff --git a/src/functions/sign.ts b/src/functions/sign.ts index a66f6010..db25af3b 100644 --- a/src/functions/sign.ts +++ b/src/functions/sign.ts @@ -2,10 +2,7 @@ import { Hash } from 'ox'; import { serializeTransaction, type Hex, type Address } from 'viem'; import bitcoin from '../bitcoin'; import { CURRENCIES } from '../constants'; -import ethereum, { - normalizeLatticeSignature, - toViemTransaction, -} from '../ethereum'; +import ethereum from '../ethereum'; import { parseGenericSigningResponse } from '../genericSigning'; import { LatticeSecureEncryptedRequestType, @@ -266,7 +263,7 @@ export const decodeSignResponse = ({ tx: `0x${result.rawTx}`, txHash: `0x${ethereum.hashTransaction(result.rawTx)}` as Hex, sig: { - v: result.sigWithV.v, + v: BigInt(`0x${result.sigWithV.v.toString('hex')}`), r: `0x${result.sigWithV.r.toString('hex')}` as Hex, s: `0x${result.sigWithV.s.toString('hex')}` as Hex, }, @@ -289,7 +286,7 @@ export const decodeSignResponse = ({ ); return { sig: { - v: validatedSig.v, + v: BigInt(`0x${validatedSig.v.toString('hex')}`), r: `0x${validatedSig.r.toString('hex')}` as Hex, s: `0x${validatedSig.s.toString('hex')}` as Hex, }, diff --git a/src/genericSigning.ts b/src/genericSigning.ts index 2f74ce40..88931d67 100644 --- a/src/genericSigning.ts +++ b/src/genericSigning.ts @@ -9,6 +9,7 @@ This payload should be coupled with: * Hash function to use on the message */ import { Hash } from 'ox'; +import { RLP } from '@ethereumjs/rlp'; // keccak256 now imported from ox via Hash module import { HARDENED_OFFSET } from './constants'; import { Constants } from './index'; @@ -17,6 +18,7 @@ import { buildSignerPathBuf, existsIn, fixLen, + getYParity, getV, parseDER, splitFrames, @@ -243,12 +245,57 @@ export const parseGenericSigningResponse = function (res, off, req) { s: `0x${sBuf.toString('hex')}`, }; - if ( - req.encodingType === Constants.SIGNING.ENCODINGS.EVM || - req.hashType === Constants.SIGNING.HASHES.KECCAK256 - ) { + if (req.encodingType === Constants.SIGNING.ENCODINGS.EVM) { + // Full EVM transaction - use getV for proper chainId/EIP-155 handling const vBn = getV(req.origPayloadBuf, parsed); parsed.sig.v = BigInt(vBn.toString()); + } else if ( + req.hashType === Constants.SIGNING.HASHES.KECCAK256 && + req.encodingType !== Constants.SIGNING.ENCODINGS.EVM + ) { + // Generic Keccak256 message - determine if it looks like a transaction + let isTransaction = false; + + try { + let bufferToDecode = req.origPayloadBuf; + + // Try to skip EIP-2718 type byte if present + if (bufferToDecode[0] <= 0x7f) { + bufferToDecode = bufferToDecode.slice(1); + } + + const decoded = RLP.decode(bufferToDecode); + // A legacy transaction has 9 fields (or 6 if pre-EIP155) + isTransaction = Array.isArray(decoded) && decoded.length >= 6; + } catch { + isTransaction = false; + } + + if (isTransaction) { + try { + // If it looks like a transaction, use the robust getV + const vBn = getV(req.origPayloadBuf, parsed); + parsed.sig.v = BigInt(vBn.toString()); + } catch (err) { + // Fall back to simple recovery if getV fails (e.g., malformed RLP) + const msgHash = Buffer.from(Hash.keccak256(req.origPayloadBuf)); + const yParity = getYParity({ + messageHash: msgHash, + signature: parsed.sig, + publicKey: parsed.pubkey, + }); + parsed.sig.v = BigInt(27 + yParity); + } + } else { + // Generic message - use simple recovery (v = 27 + recoveryId) + const msgHash = Buffer.from(Hash.keccak256(req.origPayloadBuf)); + const yParity = getYParity({ + messageHash: msgHash, + signature: parsed.sig, + publicKey: parsed.pubkey, + }); + parsed.sig.v = BigInt(27 + yParity); + } } } else if (req.curveType === Constants.SIGNING.CURVES.ED25519) { if (!req.omitPubkey) { diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 00000000..e8f45da2 --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1 @@ +export * from './transaction'; diff --git a/src/schemas/transaction.ts b/src/schemas/transaction.ts new file mode 100644 index 00000000..188206a5 --- /dev/null +++ b/src/schemas/transaction.ts @@ -0,0 +1,212 @@ +import { z } from 'zod'; +import { type Hex, isHex, hexToBigInt, isAddress, getAddress } from 'viem'; +import { TRANSACTION_TYPE } from '../types'; + +// Helper to handle various numeric inputs and convert them to BigInt. +// It also validates that the value is not negative. +const toPositiveBigInt = z + .union([ + z.string().regex(/^(0x[0-9a-fA-F]+|[0-9]+)$/, 'Invalid number format'), + z.number(), + z.bigint(), + ]) + .transform((val, ctx) => { + try { + const b = + typeof val === 'string' && isHex(val) ? hexToBigInt(val) : BigInt(val); + if (b < 0n) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Value must be non-negative', + }); + return z.NEVER; + } + return b; + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid numeric value', + }); + return z.NEVER; + } + }); + +// Schema for gas-related fields, ensuring they are non-negative BigInts. +const GasValueSchema = toPositiveBigInt.refine((val) => val >= 0n, { + message: 'Gas values must be non-negative', +}); + +// Schema for chainId, ensuring it's a positive integer. +const ChainIdSchema = z + .union([z.string(), z.number()]) + .transform((val) => + typeof val === 'string' && isHex(val) + ? Number(hexToBigInt(val as Hex)) + : Number(val), + ) + .refine((val) => Number.isInteger(val) && val > 0, { + message: 'Chain ID must be a positive integer', + }); + +// Schema for an Ethereum address, which validates and checksums it. +const AddressSchema = z + .string() + .refine(isAddress, 'Invalid address') + .transform((addr) => getAddress(addr)); + +// Schema for hex data, ensuring it's a valid hex string. +const DataSchema = z + .string() + .refine(isHex, 'Data must be a valid hex string') + .default('0x'); + +// Schema for access list entries. +const AccessListEntrySchema = z.object({ + address: AddressSchema, + storageKeys: z.array( + z.string().refine(isHex, 'Storage key must be a hex string'), + ), +}); + +// Schema for EIP-7702 authorization entries. +const AuthorizationSchema = z.object({ + chainId: z.number().int().positive(), + address: AddressSchema, + nonce: z.number().int().nonnegative(), + yParity: z.number().optional().default(0), + r: z.string().refine(isHex).optional(), + s: z.string().refine(isHex).optional(), +}); + +// Base schema for all transaction types. +const BaseTxSchema = z.object({ + to: AddressSchema.optional(), + value: toPositiveBigInt.optional(), + data: DataSchema, + nonce: z.number().int().nonnegative().optional(), + gas: GasValueSchema.optional(), + gasLimit: GasValueSchema.optional(), + chainId: ChainIdSchema.optional().default(1), + accessList: z.array(AccessListEntrySchema).optional(), +}); + +// Schema for Legacy (Type 0) transactions. +const LegacyTxSchema = BaseTxSchema.extend({ + type: z + .union([z.literal('legacy'), z.literal(TRANSACTION_TYPE.LEGACY)]) + .optional(), + gasPrice: GasValueSchema, +}); + +// Schema for EIP-2930 (Type 1) transactions. +const EIP2930TxSchema = BaseTxSchema.extend({ + type: z.union([z.literal('eip2930'), z.literal(TRANSACTION_TYPE.EIP2930)]), + gasPrice: GasValueSchema, +}); + +// Schema for EIP-1559 (Type 2) transactions. +const EIP1559TxSchema = BaseTxSchema.extend({ + type: z.union([z.literal('eip1559'), z.literal(TRANSACTION_TYPE.EIP1559)]), + maxFeePerGas: GasValueSchema, + maxPriorityFeePerGas: GasValueSchema, +}); + +// Schema for EIP-7702 (Type 4/5) transactions. +const EIP7702TxSchema = BaseTxSchema.extend({ + type: z.union([ + z.literal('eip7702'), + z.literal(TRANSACTION_TYPE.EIP7702_AUTH), + z.literal(TRANSACTION_TYPE.EIP7702_AUTH_LIST), + ]), + maxFeePerGas: GasValueSchema, + maxPriorityFeePerGas: GasValueSchema, + authorizationList: z.array(AuthorizationSchema).min(1), +}); + +/** + * A comprehensive zod schema that validates and normalizes a flexible transaction input. + * It handles: + * - Type inference (legacy, EIP-1559, etc.) based on provided fields. + * - Coercion of numbers, strings, and hex values to their correct types (BigInt, Address). + * - Validation of addresses, hex data, and transaction-specific rules. + * - Merging of `gas` and `gasLimit` fields. + */ +export const TransactionSchema = z + .any() + // Pre-process to check for circular references before zod touches it + .refine( + (val) => { + try { + JSON.stringify(val, (_, value) => + typeof value === 'bigint' ? value.toString() : value, + ); + return true; + } catch { + return false; + } + }, + { message: 'Circular reference detected in transaction object' }, + ) + .transform((tx) => { + // Prioritize gasLimit over gas + if (tx.gasLimit) { + tx.gas = tx.gasLimit; + } + // Normalize EIP-7702 `authorization` to `authorizationList` + if (tx.authorization) { + tx.authorizationList = [tx.authorization]; + } + return tx; + }) + .transform((tx: any) => { + // Type inference and validation logic + const hasAuthList = !!tx.authorizationList; + const hasMaxFee = !!tx.maxFeePerGas || !!tx.maxPriorityFeePerGas; + const hasAccessList = !!tx.accessList; + const hasGasPrice = !!tx.gasPrice; + + let type: 'eip7702' | 'eip1559' | 'eip2930' | 'legacy' = 'legacy'; + let schema: z.ZodTypeAny = LegacyTxSchema; + + if ( + tx.type === 'eip7702' || + tx.type === 4 || + tx.type === 5 || + hasAuthList + ) { + type = 'eip7702'; + schema = EIP7702TxSchema; + } else if (tx.type === 'eip1559' || tx.type === 2 || hasMaxFee) { + type = 'eip1559'; + schema = EIP1559TxSchema; + } else if (tx.type === 'eip2930' || tx.type === 1 || hasAccessList) { + type = 'eip2930'; + schema = EIP2930TxSchema; + } + + // For legacy, if gasPrice is missing, it's an invalid tx + if (type === 'legacy' && !hasGasPrice) { + throw new Error('Legacy transactions require a `gasPrice` field.'); + } + + const result = schema.parse(tx); + + // Post-process the successfully parsed data + const data: any = result; + data.type = type; + if (type === 'legacy' && data.gas === undefined) { + data.gas = 21000n; // Default gas for legacy transfers + } + + // Remove fields that are not part of the final type + if (type !== 'legacy' && type !== 'eip2930') delete data.gasPrice; + if (type !== 'eip1559' && type !== 'eip7702') { + delete data.maxFeePerGas; + delete data.maxPriorityFeePerGas; + } + if (type !== 'eip7702') delete data.authorizationList; + + return data; + }); + +export type FlexibleTransaction = z.infer; diff --git a/src/shared/functions.ts b/src/shared/functions.ts index da46354d..891b3ff9 100644 --- a/src/shared/functions.ts +++ b/src/shared/functions.ts @@ -41,7 +41,7 @@ export const buildTransaction = ({ ); let payload; try { - payload = ethereum.ethConvertLegacyToGenericReq(data); + payload = ethereum.convertEthereumTransactionToGenericRequest(data); } catch (err) { throw new Error( 'Could not convert legacy request. Please switch to a general signing ' + diff --git a/src/util.ts b/src/util.ts index 2a524c6c..0d27a51b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,5 @@ // Static utility functions import { RLP } from '@ethereumjs/rlp'; -import { Capability, TransactionFactory as EthTxFactory } from '@ethereumjs/tx'; import aes from 'aes-js'; import BigNum from 'bignumber.js'; import { BN } from 'bn.js'; @@ -11,6 +10,7 @@ import { Hash } from 'ox'; import inRange from 'lodash/inRange'; import isInteger from 'lodash/isInteger'; import secp256k1 from 'secp256k1'; +import { parseTransaction, keccak256, type Hex } from 'viem'; const EC = elliptic.ec; const { ecdsaRecover } = secp256k1; @@ -400,7 +400,7 @@ export function selectDefFrom4byteABI(abiData: any[], selector: string) { result.text_signature, ); return !!def; - } catch (err) { + } catch (_err) { return false; } }); @@ -573,7 +573,7 @@ async function replaceNestedDefs(possNestedDefs) { _nestedSelector, ); _nestedDefs.push(_nestedDef); - } catch (err) { + } catch (_err) { shouldInclude = false; _nestedDefs.push(null); } @@ -589,7 +589,7 @@ async function replaceNestedDefs(possNestedDefs) { const nestedAbi = await fetch4byteData(nestedSelector); const nestedDef = selectDefFrom4byteABI(nestedAbi, nestedSelector); nestedDefs.push(nestedDef); - } catch (err) { + } catch (_err) { nestedDefs.push(null); } } @@ -708,57 +708,102 @@ export const generateAppSecret = ( }; /** - * Generic signing does not return a `v` value like legacy ETH signing requests did. - * Get the `v` component of the signature as well as an `initV` - * parameter, which is what you need to use to re-create an `@ethereumjs/tx` - * object. There is a lot of tech debt in `@ethereumjs/tx` which also - * inherits the tech debt of ethereumjs-util. - * 1. The legacy `Transaction` type can call `_processSignature` with the regular - * `v` value. - * 2. Newer transaction types such as `FeeMarketEIP1559Transaction` will subtract - * 27 from the `v` that gets passed in, so we need to add `27` to create `initV` - * @param tx - An @ethereumjs/tx Transaction object or Buffer (serialized tx) - * @param resp - response from Lattice. Can be either legacy or generic signing variety - * @returns bn.js BN object containing the `v` param + * Get the `v` component of signature using viem parsing. + * @param tx - Serialized transaction (Buffer or hex string) + * @param resp - Lattice response with sig and pubkey + * @returns BN object containing the `v` param */ export const getV = function (tx: any, resp: any) { - let chainId, hash, type; - const txIsBuf = Buffer.isBuffer(tx); - if (txIsBuf) { - hash = Buffer.from(Hash.keccak256(tx)); + let chainId: number | undefined; + let hash: Uint8Array; + let type: string | undefined; + + if (Buffer.isBuffer(tx) || typeof tx === 'string') { + const txHex = Buffer.isBuffer(tx) + ? (`0x${tx.toString('hex')}` as Hex) + : (tx as Hex); + try { - const legacyTxArray = RLP.decode(tx); - if (legacyTxArray.length === 6) { - // Six item array means this is a pre-EIP155 transaction - chainId = null; + const parsedTx = parseTransaction(txHex); + type = parsedTx.type; + chainId = parsedTx.chainId; + + if (type === 'legacy') { + // Check if this is EIP-155 by looking at RLP structure + try { + const legacyTxArray = RLP.decode(Buffer.from(txHex.slice(2), 'hex')); + if (legacyTxArray.length === 6) { + chainId = undefined; // Pre-EIP155 + } + } catch { + // Use chainId from viem parse + } + } + + // Construct signing hash for EIP-155 legacy transactions + if (type === 'legacy' && chainId) { + const signingTx = [ + parsedTx.nonce ? `0x${parsedTx.nonce.toString(16)}` : '0x', + parsedTx.gasPrice ? `0x${parsedTx.gasPrice.toString(16)}` : '0x', + parsedTx.gas ? `0x${parsedTx.gas.toString(16)}` : '0x', + parsedTx.to || '0x', + parsedTx.value ? `0x${parsedTx.value.toString(16)}` : '0x', + parsedTx.data || '0x', + `0x${chainId.toString(16)}`, + '0x', + '0x', + ].map((val) => + val === '0x' ? Buffer.alloc(0) : Buffer.from(val.slice(2), 'hex'), + ); + + const signingRlp = RLP.encode(signingTx); + hash = Buffer.from(Hash.keccak256(signingRlp)); } else { - // Otherwise the `v` param is the `chainId` - chainId = new BN(legacyTxArray[6] as Uint8Array); + // Use transaction hash directly for non-EIP155 or typed transactions + hash = Buffer.from(keccak256(txHex).slice(2), 'hex'); } - // Legacy tx = type 0 - type = 0; } catch (err) { - // This is likely a typed transaction + // Fallback to legacy RLP decode if viem parsing fails try { - const txObj = EthTxFactory.fromSerializedData(tx); - //@ts-expect-error -- Accessing private property - type = txObj._type; - } catch (err) { - // If we can't RLP decode and can't hydrate an @ethereumjs/tx object, - // we don't know what this is and should abort. + const txBuf = Buffer.isBuffer(tx) + ? tx + : Buffer.from(tx.slice(2), 'hex'); + const legacyTxArray = RLP.decode(txBuf); + + if (legacyTxArray.length === 6) { + chainId = undefined; // Pre-EIP155 + type = 'legacy'; + } else if (legacyTxArray.length >= 9) { + const vBuf = legacyTxArray[6] as Uint8Array; + if (vBuf && vBuf.length > 0) { + chainId = new BN(vBuf).toNumber(); + } + type = 'legacy'; + } + + if (type === 'legacy' && chainId) { + // Reconstruct EIP-155 signing hash + const signingTxArray = [ + ...legacyTxArray.slice(0, 6), + chainId, + Buffer.alloc(0), + Buffer.alloc(0), + ]; + const signingRlp = RLP.encode(signingTxArray); + hash = Buffer.from(Hash.keccak256(signingRlp)); + } else { + hash = Buffer.from(Hash.keccak256(txBuf)); + } + } catch { throw new Error('Could not recover V. Bad transaction data.'); } } } else { - // @ethereumjs/tx object passed in - type = tx._type; - hash = type - ? tx.getMessageToSign(true) // newer tx types - : RLP.encode(tx.getMessageToSign(false)); // legacy tx - if (tx.supports(Capability.EIP155ReplayProtection)) { - chainId = tx.common.chainIdBN().toNumber(); - } + throw new Error( + 'Unsupported transaction format. Expected Buffer or hex string.', + ); } + const rBuf = Buffer.isBuffer(resp.sig.r) ? resp.sig.r : Buffer.from(resp.sig.r.slice(2), 'hex'); @@ -767,33 +812,73 @@ export const getV = function (tx: any, resp: any) { : Buffer.from(resp.sig.s.slice(2), 'hex'); const rs = new Uint8Array(Buffer.concat([rBuf, sBuf])); const pubkey = new Uint8Array(resp.pubkey); + const recovery0 = ecdsaRecover(rs, 0, hash, false); const recovery1 = ecdsaRecover(rs, 1, hash, false); const pubkeyStr = Buffer.from(pubkey).toString('hex'); const recovery0Str = Buffer.from(recovery0).toString('hex'); const recovery1Str = Buffer.from(recovery1).toString('hex'); - let recovery; + + let recovery: number; if (pubkeyStr === recovery0Str) { recovery = 0; } else if (pubkeyStr === recovery1Str) { recovery = 1; } else { - // If we fail a second time, exit here. throw new Error( 'Failed to recover V parameter. Bad signature or transaction data.', ); } - // Newer transaction types just use the [0, 1] value - if (type) { - return new BN(recovery); + + // Use the consolidated v parameter conversion logic + const result = convertRecoveryToV(recovery, { + chainId, + useEIP155: !!chainId, + type, + }); + + // Always return BN for consistent interface - convertRecoveryToV returns Buffer for typed txs + if (Buffer.isBuffer(result)) { + // For typed transactions that return recovery value (0 or 1) as buffer + if (result.length === 0) { + return new BN(0); // Empty buffer means 0 + } else { + return new BN(result.toString('hex'), 16); + } + } else { + return result; // Already a BN } - // If there is no chain ID, this is a pre-EIP155 tx - if (!chainId) { +}; + +/** + * Convert a recovery parameter (0/1) to the proper v value format based on transaction type. + * Consolidates the v parameter conversion logic used across ethereum.ts and util.ts. + * + * @param recovery - Recovery parameter (0 or 1) + * @param txData - Transaction data containing chainId, useEIP155, and type + * @returns The properly formatted v value as Buffer or BN + */ +export const convertRecoveryToV = function ( + recovery: number, + txData: any = {}, +) { + const { chainId, useEIP155, type } = txData; + + // For EIP1559 and EIP2930 transactions, we want the recoveryParam (0 or 1) + // rather than the `v` value because the `chainId` is already included in the + // transaction payload. + if (type === 1 || type === 2 || type === 'eip2930' || type === 'eip1559') { + return ensureHexBuffer(recovery, true); // 0 or 1, with 0 expected as an empty buffer + } else if (!useEIP155 || !chainId) { + // For ETH messages and non-EIP155 chains the set should be [27, 28] for `v` return new BN(recovery).addn(27); } - // EIP155 replay protection is included in the `v` param - // and uses the chainId value. - return chainId.muln(2).addn(35).addn(recovery); + + // We will use EIP155 in most cases. Convert recovery to a bignum and operate on it. + // Note that the protocol calls for v = (CHAIN_ID*2) + 35/36, where 35 or 36 + // is decided on based on the ecrecover result. `recovery` is passed in as either 0 or 1 + // so we add 35 to that. + return new BN(chainId).muln(2).addn(35).addn(recovery); }; /** @@ -915,4 +1000,5 @@ export const EXTERNAL = { generateAppSecret, getV, getYParity, + convertRecoveryToV, }; diff --git a/src/utils/transaction-parsing.ts b/src/utils/transaction-parsing.ts new file mode 100644 index 00000000..4e48ed83 --- /dev/null +++ b/src/utils/transaction-parsing.ts @@ -0,0 +1,163 @@ +import { type Hex, isHex, hexToBigInt, isAddress, getAddress } from 'viem'; + +/** + * Parse a value to a non-negative BigInt using viem utilities + */ +export function parsePositiveBigInt( + value: string | number | bigint | Hex | undefined, +): bigint | undefined { + if (value === undefined) return undefined; + + try { + // Use viem's hexToBigInt for hex strings, otherwise BigInt constructor + const bigIntValue = + typeof value === 'string' && isHex(value) + ? hexToBigInt(value as Hex) + : BigInt(value); + + if (bigIntValue < 0n) { + throw new Error('Value must be non-negative'); + } + return bigIntValue; + } catch (error) { + if ( + error instanceof Error && + error.message === 'Value must be non-negative' + ) { + throw error; + } + throw new Error('Invalid value format'); + } +} + +/** + * Parse a gas value to a non-negative BigInt using viem utilities + */ +export function parseGasValue( + value: string | number | bigint | Hex | undefined, +): bigint | undefined { + if (value === undefined) return undefined; + + try { + // Use viem's hexToBigInt for hex strings, otherwise BigInt constructor + const bigIntValue = + typeof value === 'string' && isHex(value) + ? hexToBigInt(value as Hex) + : BigInt(value); + + if (bigIntValue < 0n) { + throw new Error('Gas values must be non-negative'); + } + return bigIntValue; + } catch (error) { + if ( + error instanceof Error && + error.message === 'Gas values must be non-negative' + ) { + throw error; + } + throw new Error('Invalid gas value format'); + } +} + +/** + * Parse a chain ID using viem utilities for hex handling + */ +export function parseChainId(value: string | number | undefined): number { + if (value === undefined) return 1; + + // Handle hex chainId values using viem + const numValue = + typeof value === 'string' && isHex(value) + ? Number(hexToBigInt(value as Hex)) + : Number(value); + + if (!Number.isInteger(numValue) || numValue <= 0) { + throw new Error('ChainId must be a positive integer'); + } + return numValue; +} + +/** + * Validate and normalize authorization object using viem utilities + */ +export function validateAuthorization(auth: any): { + chainId: number; + address: `0x${string}`; + nonce: number; + r?: `0x${string}`; + s?: `0x${string}`; + yParity?: number; +} { + if (!auth || typeof auth !== 'object') { + throw new Error('Authorization must be an object'); + } + + const chainId = Number(auth.chainId); + if (!Number.isInteger(chainId) || chainId <= 0) { + throw new Error('Authorization chainId must be a positive integer'); + } + + // Use viem's isAddress for validation and getAddress for checksum format + if (!auth.address || !isAddress(auth.address, { strict: false })) { + throw new Error('Authorization address must be a valid hex address'); + } + + const nonce = Number(auth.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + throw new Error('Authorization nonce must be a non-negative integer'); + } + + const result: any = { + chainId, + address: getAddress(auth.address), // Ensures proper checksum format + nonce, + yParity: auth.yParity ?? 0, + }; + + // Use viem's isHex for hex string validation + if (auth.r && !isHex(auth.r)) { + throw new Error('Authorization r must be a valid hex string'); + } + if (auth.s && !isHex(auth.s)) { + throw new Error('Authorization s must be a valid hex string'); + } + + if (auth.r) result.r = auth.r as `0x${string}`; + if (auth.s) result.s = auth.s as `0x${string}`; + + return result; +} + +/** + * Parse transaction fields with validation using viem utilities + */ +export function parseTransactionFields(tx: any): { + chainId: number; + value: bigint | undefined; + nonce: number; + gas: bigint | undefined; + maxFeePerGas: bigint | undefined; + maxPriorityFeePerGas: bigint | undefined; + gasPrice: bigint | undefined; +} { + try { + const nonceValue = + tx.nonce !== undefined ? parsePositiveBigInt(tx.nonce) : 0n; + + return { + chainId: parseChainId(tx.chainId), + value: parsePositiveBigInt(tx.value), + nonce: Number(nonceValue), + gas: tx.gasLimit ? parseGasValue(tx.gasLimit) : parseGasValue(tx.gas), + maxFeePerGas: parseGasValue(tx.maxFeePerGas), + maxPriorityFeePerGas: parseGasValue(tx.maxPriorityFeePerGas), + gasPrice: parseGasValue(tx.gasPrice), + }; + } catch (err) { + if (err instanceof Error) { + throw new Error(`Invalid transaction field: ${err.message}`); + } + throw err; + } +} From cbf84bb3b0089733f32753cffb805b44a9441bf8 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:21:10 -0400 Subject: [PATCH 4/6] fix lock --- pnpm-lock.yaml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c194c7c..dd3f54d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 4.17.21 ox: specifier: ^0.8.1 - version: 0.8.1(typescript@5.8.3)(zod@4.1.5) + version: 0.8.1(typescript@5.8.3)(zod@3.25.76) secp256k1: specifier: 5.0.1 version: 5.0.1 @@ -73,10 +73,10 @@ importers: version: 10.0.0 viem: specifier: ^2.31.4 - version: 2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.5) + version: 2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: - specifier: ^4.1.5 - version: 4.1.5 + specifier: ^3.23.8 + version: 3.25.76 devDependencies: '@chainsafe/bls-keystore': specifier: ^3.1.0 @@ -3110,8 +3110,8 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} - zod@4.1.5: - resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: @@ -4155,10 +4155,10 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - abitype@1.0.8(typescript@5.8.3)(zod@4.1.5): + abitype@1.0.8(typescript@5.8.3)(zod@3.25.76): optionalDependencies: typescript: 5.8.3 - zod: 4.1.5 + zod: 3.25.76 acorn-jsx@5.3.2(acorn@8.13.0): dependencies: @@ -5350,7 +5350,7 @@ snapshots: outvariant@1.4.3: {} - ox@0.8.1(typescript@5.8.3)(zod@4.1.5): + ox@0.8.1(typescript@5.8.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/ciphers': 1.3.0 @@ -5358,7 +5358,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@4.1.5) + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -5869,15 +5869,15 @@ snapshots: dependencies: safe-buffer: 5.2.1 - viem@2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.5): + viem@2.31.4(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@noble/curves': 1.9.2 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.0.8(typescript@5.8.3)(zod@4.1.5) + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) isows: 1.0.7(ws@8.18.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - ox: 0.8.1(typescript@5.8.3)(zod@4.1.5) + ox: 0.8.1(typescript@5.8.3)(zod@3.25.76) ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -6062,4 +6062,4 @@ snapshots: yoctocolors-cjs@2.1.2: {} - zod@4.1.5: {} + zod@3.25.76: {} From b018c9eee5163542677aad88c9919399275065fe Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Fri, 12 Sep 2025 08:29:53 -0400 Subject: [PATCH 5/6] address feedback --- src/__test__/unit/parseGenericSigningResponse.test.ts | 5 ++--- src/__test__/unit/signatureUtils.test.ts | 7 ++----- src/functions/sign.ts | 4 +--- src/util.ts | 2 +- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/__test__/unit/parseGenericSigningResponse.test.ts b/src/__test__/unit/parseGenericSigningResponse.test.ts index f5be16ca..46235dd0 100644 --- a/src/__test__/unit/parseGenericSigningResponse.test.ts +++ b/src/__test__/unit/parseGenericSigningResponse.test.ts @@ -4,6 +4,7 @@ import { parseGenericSigningResponse } from '../../genericSigning'; import { Constants } from '../../index'; import secp256k1 from 'secp256k1'; import { Hash } from 'ox'; +import { RLP } from '@ethereumjs/rlp'; describe('parseGenericSigningResponse', () => { // Helper to create a DER signature @@ -69,8 +70,7 @@ describe('parseGenericSigningResponse', () => { expect(typeof result.sig.v).toBe('bigint'); // For non-EVM generic messages, v should be 27 or 28 - const vNumber = Number(result.sig.v); - expect([27n, 28n]).toContain(result.sig.v); + expect([27, 28]).toContain(result.sig.v); }); it('should handle EVM transaction encoding', () => { @@ -123,7 +123,6 @@ describe('parseGenericSigningResponse', () => { it('should handle RLP-encoded data that looks like a transaction', () => { // Create an RLP-encoded array with 6+ elements (looks like a transaction) - const RLP = require('@ethereumjs/rlp').RLP; const txLikeData = [ Buffer.from([0x01]), // nonce Buffer.from([0x02]), // gasPrice diff --git a/src/__test__/unit/signatureUtils.test.ts b/src/__test__/unit/signatureUtils.test.ts index 0cbf5a8f..b4d9dc6a 100644 --- a/src/__test__/unit/signatureUtils.test.ts +++ b/src/__test__/unit/signatureUtils.test.ts @@ -408,8 +408,7 @@ describe('getV function', () => { expect(v.toNumber()).toBe(27 + resp.recovery); }); - it('should parse viem parseTransaction correctly', () => { - // Test that we're using viem's parseTransaction correctly + it('should throw error when pubkey does not match signature', () => { // This is a signed legacy transaction const signedTx = '0xf86c0a8504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a0134f5038e0e6a96741e17a82c8df13e9dc10c3b0e9e956cf7dcf21e1e3b73f9fa0638cf1b1f9dd5e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8e6e8e6b9a8'; @@ -429,11 +428,10 @@ describe('getV function', () => { pubkey: Buffer.from('04' + '1'.repeat(128), 'hex'), }; - // Should throw because pubkey doesn't match expect(() => getV(signedTx, mockResp)).toThrow(); }); - it('should handle hex string input', () => { + it('should throw error when signature is invalid', () => { const txHex = '0xe9808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080'; @@ -445,7 +443,6 @@ describe('getV function', () => { pubkey: Buffer.from('04' + '1'.repeat(128), 'hex'), }; - // Should throw because signature doesn't match expect(() => getV(txHex, mockResp)).toThrow(); }); }); diff --git a/src/functions/sign.ts b/src/functions/sign.ts index db25af3b..a18efca2 100644 --- a/src/functions/sign.ts +++ b/src/functions/sign.ts @@ -1,5 +1,5 @@ import { Hash } from 'ox'; -import { serializeTransaction, type Hex, type Address } from 'viem'; +import { type Hex, type Address } from 'viem'; import bitcoin from '../bitcoin'; import { CURRENCIES } from '../constants'; import ethereum from '../ethereum'; @@ -19,8 +19,6 @@ import { DecodeSignResponseParams, SignData, BitcoinSignRequest, - EthSignRequest, - EthMsgSignRequest, SignRequest, } from '../types'; diff --git a/src/util.ts b/src/util.ts index 0d27a51b..99e7a553 100644 --- a/src/util.ts +++ b/src/util.ts @@ -861,7 +861,7 @@ export const getV = function (tx: any, resp: any) { export const convertRecoveryToV = function ( recovery: number, txData: any = {}, -) { +): Buffer | InstanceType { const { chainId, useEIP155, type } = txData; // For EIP1559 and EIP2930 transactions, we want the recoveryParam (0 or 1) From dc606c2248fd14bef8732f5d500ff0f715dc0f06 Mon Sep 17 00:00:00 2001 From: netbonus <151201453+netbonus@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:15:32 -0400 Subject: [PATCH 6/6] fix bigint --- src/__test__/unit/parseGenericSigningResponse.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__test__/unit/parseGenericSigningResponse.test.ts b/src/__test__/unit/parseGenericSigningResponse.test.ts index 46235dd0..d86cc26f 100644 --- a/src/__test__/unit/parseGenericSigningResponse.test.ts +++ b/src/__test__/unit/parseGenericSigningResponse.test.ts @@ -70,7 +70,7 @@ describe('parseGenericSigningResponse', () => { expect(typeof result.sig.v).toBe('bigint'); // For non-EVM generic messages, v should be 27 or 28 - expect([27, 28]).toContain(result.sig.v); + expect([27n, 28n]).toContain(result.sig.v); }); it('should handle EVM transaction encoding', () => {