diff --git a/packages/aws-kms-adapter/src/KMSVeChainSigner.ts b/packages/aws-kms-adapter/src/KMSVeChainSigner.ts index 84904c663..611659136 100644 --- a/packages/aws-kms-adapter/src/KMSVeChainSigner.ts +++ b/packages/aws-kms-adapter/src/KMSVeChainSigner.ts @@ -1,7 +1,7 @@ import { bytesToHex, concatBytes } from '@noble/curves/abstract/utils'; import { type SignatureType } from '@noble/curves/abstract/weierstrass'; import { secp256k1 } from '@noble/curves/secp256k1'; -import { Address, Hex, Keccak256, Transaction, Txt } from '@vechain/sdk-core'; +import { Address, Hex, Transaction } from '@vechain/sdk-core'; import { JSONRPCInvalidParams, SignerMethodError } from '@vechain/sdk-errors'; import { type AvailableVeChainProviders, @@ -11,13 +11,7 @@ import { VeChainAbstractSigner } from '@vechain/sdk-network'; import { BitString, ObjectIdentifier, Sequence, verifySchema } from 'asn1js'; -import { - hashTypedData, - recoverPublicKey, - toHex, - type TypedDataDomain, - type TypedDataParameter -} from 'viem'; +import { recoverPublicKey, toHex } from 'viem'; import { KMSVeChainProvider } from './KMSVeChainProvider'; class KMSVeChainSigner extends VeChainAbstractSigner { @@ -157,7 +151,7 @@ class KMSVeChainSigner extends VeChainAbstractSigner { } /** - * It builds a VeChain signature from a bytes payload. + * It builds a VeChain signature from a bytes' payload. * @param {Uint8Array} payload to sign. * @param {KMSVeChainProvider} kmsProvider The provider to sign the payload. * @returns {Uint8Array} The signature following the VeChain format. @@ -188,12 +182,10 @@ class KMSVeChainSigner extends VeChainAbstractSigner { kmsProvider ); - const decodedSignature = concatBytes( + return concatBytes( decodedSignatureWithoutRecoveryBit.toCompactRawBytes(), new Uint8Array([recoveryBit]) ); - - return decodedSignature; } /** @@ -314,7 +306,7 @@ class KMSVeChainSigner extends VeChainAbstractSigner { /** * Submits a signed transaction to the network. - * @param transactionToSend Transaction to by signed and sent to the network. + * @param transactionToSend Transaction to be signed and sent to the network. * @returns {string} The transaction ID. */ public async sendTransaction( @@ -345,69 +337,13 @@ class KMSVeChainSigner extends VeChainAbstractSigner { * @param {Uint8Array} payload in bytes to sign. * @returns {string} The VeChain signature in hexadecimal format. */ - private async signPayload(payload: Uint8Array): Promise { + public async signPayload(payload: Uint8Array): Promise { const veChainSignature = await this.buildVeChainSignatureFromPayload(payload); // SCP256K1 encodes the recovery flag in the last byte. EIP-191 adds 27 to it. veChainSignature[veChainSignature.length - 1] += 27; return Hex.of(veChainSignature).toString(); } - - /** - * Signs a message returning the VeChain signature in hexadecimal format. - * @param {string | Uint8Array} message to sign. - * @returns {string} The VeChain signature in hexadecimal format. - */ - public async signMessage(message: string | Uint8Array): Promise { - try { - const payload = - typeof message === 'string' ? Txt.of(message).bytes : message; - const payloadHashed = Keccak256.of( - concatBytes( - this.MESSAGE_PREFIX, - Txt.of(payload.length).bytes, - payload - ) - ).bytes; - return await this.signPayload(payloadHashed); - } catch (error) { - throw new SignerMethodError( - 'KMSVeChainSigner.signMessage', - 'The message could not be signed.', - { message }, - error - ); - } - } - - /** - * Signs a typed data returning the VeChain signature in hexadecimal format. - * @param {TypedDataDomain} domain to hash as typed data. - * @param {Record} types to hash as typed data. - * @param {Record} value to hash as typed data. - * @returns {string} The VeChain signature in hexadecimal format. - */ - public async signTypedData( - domain: TypedDataDomain, - types: Record, - primaryType: string, - message: Record - ): Promise { - try { - const payload = Hex.of( - hashTypedData({ domain, types, primaryType, message }) - ).bytes; - - return await this.signPayload(payload); - } catch (error) { - throw new SignerMethodError( - 'KMSVeChainSigner.signTypedData', - 'The typed data could not be signed.', - { domain, types, primaryType, message }, - error - ); - } - } } export { KMSVeChainSigner }; diff --git a/packages/aws-kms-adapter/tests/KMSVeChainSigner.solo.test.ts b/packages/aws-kms-adapter/tests/KMSVeChainSigner.solo.test.ts index 6ad998f5b..cd4f33379 100644 --- a/packages/aws-kms-adapter/tests/KMSVeChainSigner.solo.test.ts +++ b/packages/aws-kms-adapter/tests/KMSVeChainSigner.solo.test.ts @@ -299,12 +299,27 @@ describe('KMSVeChainSigner - Thor Solo', () => { const signature = await signer.signTypedData( typedData.domain, typedData.types, - typedData.primaryType, - typedData.data + typedData.data, + typedData.primaryType ); expect(signature).toBeDefined(); // 64-bytes hex string - expect(signature.length).toBe(132); + expect(signature).toMatch(/^0x[A-Fa-f0-9]{130}$/); + + const signatureWithoutPrimaryType = await signer.signTypedData( + typedData.domain, + typedData.types, + typedData.data + ); + expect(signatureWithoutPrimaryType).toBeDefined(); + // 64-bytes hex string + expect(signatureWithoutPrimaryType).toMatch(/^0x[A-Fa-f0-9]{130}$/); + + // Not checking directly the signatures since there is an issue in LocalStack: + // https://github.com/localstack/localstack/issues/11678 + // Looks like, regardless the configuration, a new SECP256r1 key is generated + // meaning that the signature will be different every time. + // However both hashes have been checked and they match, + tests in the other implementation. }); }); }); diff --git a/packages/aws-kms-adapter/tests/KMSVeChainSigner.unit.test.ts b/packages/aws-kms-adapter/tests/KMSVeChainSigner.unit.test.ts index 90b76fa91..9dad5a409 100644 --- a/packages/aws-kms-adapter/tests/KMSVeChainSigner.unit.test.ts +++ b/packages/aws-kms-adapter/tests/KMSVeChainSigner.unit.test.ts @@ -3,9 +3,10 @@ import { JSONRPCInvalidParams, SignerMethodError } from '@vechain/sdk-errors'; import { VeChainProvider, type ThorClient, - type TransactionRequestInput + type TransactionRequestInput, + type TypedDataDomain, + type TypedDataParameter } from '@vechain/sdk-network'; -import { type TypedDataDomain, type TypedDataParameter } from 'viem'; import { KMSVeChainProvider, KMSVeChainSigner } from '../src'; import { EIP712_CONTRACT, EIP712_FROM, EIP712_TO } from './fixture'; jest.mock('asn1js', () => ({ @@ -136,7 +137,6 @@ describe('KMSVeChainSigner', () => { } ] }, - 'Mail', { from: { name: 'Cow', @@ -147,7 +147,8 @@ describe('KMSVeChainSigner', () => { wallet: EIP712_TO }, contents: 'Hello, Bob!' - } + }, + 'Mail' ) ).rejects.toThrow(SignerMethodError); }); @@ -161,8 +162,8 @@ describe('KMSVeChainSigner', () => { signer.signTypedData( {} as unknown as TypedDataDomain, {} as unknown as Record, - 'primaryType', - {} as unknown as Record + {} as unknown as Record, + 'primaryType' ) ).rejects.toThrow(SignerMethodError); }); diff --git a/packages/network/src/provider/utils/rpc-mapper/methods/eth_signTypedData_v4/eth_signTypedData_v4.ts b/packages/network/src/provider/utils/rpc-mapper/methods/eth_signTypedData_v4/eth_signTypedData_v4.ts index b8ff51365..9ae31685b 100644 --- a/packages/network/src/provider/utils/rpc-mapper/methods/eth_signTypedData_v4/eth_signTypedData_v4.ts +++ b/packages/network/src/provider/utils/rpc-mapper/methods/eth_signTypedData_v4/eth_signTypedData_v4.ts @@ -4,8 +4,11 @@ import { JSONRPCInvalidParams, stringifyData } from '@vechain/sdk-errors'; -import type { TypedDataDomain, TypedDataParameter } from 'viem'; -import type { VeChainSigner } from '../../../../../signer/signers'; +import type { + TypedDataDomain, + TypedDataParameter, + VeChainSigner +} from '../../../../../signer/signers'; import type { ThorClient } from '../../../../../thor-client'; import type { VeChainProvider } from '../../../../providers/vechain-provider'; @@ -74,8 +77,8 @@ const ethSignTypedDataV4 = async ( return await signer.signTypedData( typedData.domain, typedData.types, - typedData.primaryType, - typedData.message + typedData.message, + typedData.primaryType ); } catch (error) { throw new JSONRPCInternalError( diff --git a/packages/network/src/signer/signers/types.d.ts b/packages/network/src/signer/signers/types.d.ts index eac69d4a3..cd320daa6 100644 --- a/packages/network/src/signer/signers/types.d.ts +++ b/packages/network/src/signer/signers/types.d.ts @@ -1,5 +1,8 @@ import { type TransactionClause } from '@vechain/sdk-core'; -import { type TypedDataDomain, type TypedDataParameter } from 'viem'; +import type { + TypedDataDomain as viemTypedDataDomain, + TypedDataParameter as viemTypedDataParameter +} from 'viem'; import { type HardhatVeChainProvider, type VeChainProvider @@ -13,6 +16,13 @@ import { */ type AvailableVeChainProviders = VeChainProvider | HardhatVeChainProvider; +/** + * EIP-712 types in case we change the provider (viem as of now) + */ + +type TypedDataDomain = viemTypedDataDomain; +type TypedDataParameter = viemTypedDataParameter; + /** * Type for transaction input * @@ -371,8 +381,8 @@ interface VeChainSigner { signTypedData: ( domain: TypedDataDomain, types: Record, - primaryType: string, message: Record, + primaryType?: string, options?: SignTypedDataOptions ) => Promise; @@ -384,6 +394,8 @@ interface VeChainSigner { export { type AvailableVeChainProviders, + type TypedDataDomain, + type TypedDataParameter, type SignTypedDataOptions, type TransactionRequestInput, type VeChainSigner diff --git a/packages/network/src/signer/signers/vechain-abstract-signer/vechain-abstract-signer.ts b/packages/network/src/signer/signers/vechain-abstract-signer/vechain-abstract-signer.ts index 60a869861..f8ccc943d 100644 --- a/packages/network/src/signer/signers/vechain-abstract-signer/vechain-abstract-signer.ts +++ b/packages/network/src/signer/signers/vechain-abstract-signer/vechain-abstract-signer.ts @@ -1,21 +1,28 @@ +import { concatBytes } from '@noble/curves/abstract/utils'; import { Address, Clause, Hex, HexUInt, + Keccak256, Txt, type TransactionBody, type TransactionClause } from '@vechain/sdk-core'; -import { InvalidDataType, JSONRPCInvalidParams } from '@vechain/sdk-errors'; -import { type TypedDataDomain, type TypedDataParameter } from 'viem'; +import { + InvalidDataType, + JSONRPCInvalidParams, + SignerMethodError +} from '@vechain/sdk-errors'; +import { hashTypedData } from 'viem'; import { RPC_METHODS } from '../../../provider/utils/const/rpc-mapper/rpc-methods'; import { type TransactionSimulationResult } from '../../../thor-client'; import { vnsUtils } from '../../../utils'; import { type AvailableVeChainProviders, - type SignTypedDataOptions, type TransactionRequestInput, + type TypedDataDomain, + type TypedDataParameter, type VeChainSigner } from '../types'; @@ -321,6 +328,13 @@ abstract class VeChainAbstractSigner implements VeChainSigner { transactionToSend: TransactionRequestInput ): Promise; + /** + * Signs a bytes payload returning the VeChain signature in hexadecimal format. + * @param {Uint8Array} payload in bytes to sign. + * @returns {string} The VeChain signature in hexadecimal format. + */ + abstract signPayload(payload: Uint8Array): Promise; + /** * Signs an [[link-eip-191]] prefixed a personal message. * @@ -330,25 +344,107 @@ abstract class VeChainAbstractSigner implements VeChainSigner { * so the string ``"0x1234"`` is signed as six characters, **not** two bytes. * @return {Promise} - A Promise that resolves to the signature as a string. */ - abstract signMessage(message: string | Uint8Array): Promise; + public async signMessage(message: string | Uint8Array): Promise { + try { + const payload = + typeof message === 'string' ? Txt.of(message).bytes : message; + const payloadHashed = Keccak256.of( + concatBytes( + this.MESSAGE_PREFIX, + Txt.of(payload.length).bytes, + payload + ) + ).bytes; + return await this.signPayload(payloadHashed); + } catch (error) { + throw new SignerMethodError( + 'VeChainAbstractSigner.signMessage', + 'The message could not be signed.', + { message }, + error + ); + } + } + + /** + * Deduces the primary from the types if not given. + * The primary type will be the only type that is not used in any other type. + * @param {Record} types - The types used for EIP712. + * @returns {string} The primary type. + */ + private deducePrimaryType( + types: Record + ): string { + const parents = new Map(); + + // Initialize parents map + Object.keys(types).forEach((type) => { + parents.set(type, []); + }); + + // Populate parents map + for (const name in types) { + for (const field of types[name]) { + // In case the type is an array, we get its prefix + const type = field.type.split('[')[0]; + if (parents.has(type)) { + parents.get(type)?.push(name); + } + } + } + + // Find primary types + const primaryTypes = Array.from(parents.keys()).filter( + (n) => parents.get(n)?.length === 0 + ); + + if (primaryTypes.length !== 1) { + throw new SignerMethodError( + 'VeChainAbstractSigner.deducePrimaryType', + 'Ambiguous primary types or unused types.', + { primaryTypes: primaryTypes.join(', ') } + ); + } + + return primaryTypes[0]; + } /** * Signs the [[link-eip-712]] typed data. * * @param {TypedDataDomain} domain - The domain parameters used for signing. * @param {Record} types - The types used for signing. - * @param {string} primaryType - The primary type used for signing. * @param {Record} message - The message data to be signed. + * @param {string} primaryType - The primary type used for signing. * * @return {Promise} - A promise that resolves with the signature string. */ - abstract signTypedData( + public async signTypedData( domain: TypedDataDomain, types: Record, - primaryType: string, message: Record, - options?: SignTypedDataOptions - ): Promise; + primaryType?: string + ): Promise { + try { + const payload = Hex.of( + hashTypedData({ + domain, + types, + primaryType: primaryType ?? this.deducePrimaryType(types), // Deduce the primary type if not provided + message + }) + ).bytes; + + return await this.signPayload(payload); + } catch (error) { + throw new SignerMethodError( + 'VeChainAbstractSigner.signTypedData', + 'The typed data could not be signed.', + { domain, types, message, primaryType }, + error + ); + } + } /** * Use vet.domains to resolve name to address diff --git a/packages/network/src/signer/signers/vechain-private-key-signer/vechain-private-key-signer.ts b/packages/network/src/signer/signers/vechain-private-key-signer/vechain-private-key-signer.ts index 069f53cee..c41a50ef8 100644 --- a/packages/network/src/signer/signers/vechain-private-key-signer/vechain-private-key-signer.ts +++ b/packages/network/src/signer/signers/vechain-private-key-signer/vechain-private-key-signer.ts @@ -1,24 +1,15 @@ -import * as n_utils from '@noble/curves/abstract/utils'; import { Address, Hex, HexUInt, - Keccak256, Secp256k1, Transaction, - type TransactionBody, - Txt + type TransactionBody } from '@vechain/sdk-core'; import { InvalidSecp256k1PrivateKey, - JSONRPCInvalidParams, - stringifyData + JSONRPCInvalidParams } from '@vechain/sdk-errors'; -import { - hashTypedData, - type TypedDataDomain, - type TypedDataParameter -} from 'viem'; import { RPC_METHODS } from '../../../provider/utils/const/rpc-mapper/rpc-methods'; import { DelegationHandler, @@ -156,93 +147,16 @@ class VeChainPrivateKeySigner extends VeChainAbstractSigner { } /** - * Signs an [EIP-191](https://eips.ethereum.org/EIPS/eip-191) prefixed a personal message. - * - * This function is a drop-in replacement for {@link ethers.BaseWallet.signMessage} function. + * Signs a payload. * - * @param {string|Uint8Array} message - The message to be signed. - * If the %%message%% is a string, it is signed as UTF-8 encoded bytes. - * It is **not** interpreted as a [[BytesLike]]; - * so the string ``"0x1234"`` is signed as six characters, **not** two bytes. + * @param {Uint8Array} payload - The payload to be signed as a byte array * @return {Promise} - A Promise that resolves to the signature as a string. */ - async signMessage(message: string | Uint8Array): Promise { - return await new Promise((resolve, reject) => { - try { - const body = - typeof message === 'string' - ? Txt.of(message).bytes - : message; - const sign = Secp256k1.sign( - Keccak256.of( - n_utils.concatBytes( - this.MESSAGE_PREFIX, - Txt.of(body.length).bytes, - body - ) - ).bytes, - new Uint8Array(this.privateKey) - ); - // SCP256K1 encodes the recovery flag in the last byte. EIP-191 adds 27 to it. - sign[sign.length - 1] += 27; - resolve(Hex.of(sign).toString()); - } catch (e) { - const error = - e instanceof Error - ? e - : new Error( - e !== undefined - ? stringifyData(e) - : 'Error while signing the message' - ); - reject(error); - } - }); - } - - /** - * Signs the [[link-eip-712]] typed data. - * - * This function is a drop-in replacement for {@link ethers.BaseWallet.signTypedData} function, - * albeit Ethereum Name Services are not resolved because he resolution depends on **ethers** provider implementation. - * - * @param {TypedDataDomain} domain - The domain parameters used for signing. - * @param {Record} types - The types used for signing. - * @param {string} primaryType - The primary type used for signing. - * @param {Record} message - The value data to be signed. - * - * @return {Promise} - A promise that resolves with the signature string. - */ - async signTypedData( - domain: TypedDataDomain, - types: Record, - primaryType: string, - message: Record - ): Promise { - return await new Promise((resolve, reject) => { - try { - const hash = Hex.of( - hashTypedData({ domain, types, primaryType, message }) - ).bytes; - const sign = Secp256k1.sign( - hash, - new Uint8Array(this.privateKey) - ); - // SCP256K1 encodes the recovery flag in the last byte. EIP-712 adds 27 to it. - sign[sign.length - 1] += 27; - resolve(Hex.of(sign).toString()); - } catch (e) { - const error = - e instanceof Error - ? e - : new Error( - e !== undefined - ? stringifyData(e) - : 'Error while signing typed data' - ); - reject(error); - } - }); + async signPayload(payload: Uint8Array): Promise { + const sign = Secp256k1.sign(payload, new Uint8Array(this.privateKey)); + // SCP256K1 encodes the recovery flag in the last byte. EIP-191 adds 27 to it. + sign[sign.length - 1] += 27; + return await Promise.resolve(Hex.of(sign).toString()); } /** diff --git a/packages/network/tests/signer/signers/vechain-private-key-signer/vechain-private-key-signer.unit.test.ts b/packages/network/tests/signer/signers/vechain-private-key-signer/vechain-private-key-signer.unit.test.ts index 922ab973e..7aed3fa98 100644 --- a/packages/network/tests/signer/signers/vechain-private-key-signer/vechain-private-key-signer.unit.test.ts +++ b/packages/network/tests/signer/signers/vechain-private-key-signer/vechain-private-key-signer.unit.test.ts @@ -7,6 +7,7 @@ import { test } from '@jest/globals'; import { Address, Hex, HexUInt, Secp256k1, Txt } from '@vechain/sdk-core'; +import { SignerMethodError } from '@vechain/sdk-errors'; import { Wallet } from 'ethers'; import { TESTNET_URL, @@ -22,7 +23,6 @@ import { populateCallTestCases, populateCallTestCasesAccount } from './fixture'; -import { InvalidAbiEncodingTypeError } from 'viem'; /** * VeChain base signer tests @@ -254,7 +254,11 @@ describe('VeChain base signer tests', () => { await expect( signer.signMessage(EIP191_MESSAGE) - ).rejects.toThrowError('Error while signing the message'); + ).rejects.toThrowError( + `Method 'VeChainAbstractSigner.signMessage' failed.` + + `\n-Reason: 'The message could not be signed.'` + + `\n-Parameters: \n\t{\n "message": "Hello world! - こんにちは世界 - 👋🗺️!"\n}` + ); }); test('signMessage - ethers compatible - string', async () => { @@ -291,10 +295,10 @@ describe('VeChain base signer tests', () => { signer.signTypedData( eip712TestCases.invalid.domain, eip712TestCases.invalid.types, - eip712TestCases.invalid.primaryType, - eip712TestCases.invalid.data + eip712TestCases.invalid.data, + eip712TestCases.invalid.primaryType ) - ).rejects.toThrowError(InvalidAbiEncodingTypeError); + ).rejects.toThrowError(SignerMethodError); }); test('signTypedData - exception when parsing to hex', async () => { @@ -315,8 +319,8 @@ describe('VeChain base signer tests', () => { signer.signTypedData( eip712TestCases.valid.domain, eip712TestCases.valid.types, - eip712TestCases.valid.primaryType, - eip712TestCases.valid.data + eip712TestCases.valid.data, + eip712TestCases.valid.primaryType ) ).rejects.toThrowError(expectedErrorString); @@ -324,10 +328,10 @@ describe('VeChain base signer tests', () => { signer.signTypedData( eip712TestCases.valid.domain, eip712TestCases.valid.types, - eip712TestCases.valid.primaryType, - eip712TestCases.valid.data + eip712TestCases.valid.data, + eip712TestCases.valid.primaryType ) - ).rejects.toThrowError('Error while signing typed data'); + ).rejects.toThrowError(SignerMethodError); }); test('signTypedData - ethers compatible', async () => { @@ -339,16 +343,24 @@ describe('VeChain base signer tests', () => { eip712TestCases.valid.data ); expect(expected).toBe(eip712TestCases.valid.signature); - const actual = await new VeChainPrivateKeySigner( + const privateKeySigner = new VeChainPrivateKeySigner( Hex.of(eip712TestCases.valid.privateKey).bytes, provider - ).signTypedData( + ); + const actual = await privateKeySigner.signTypedData( eip712TestCases.valid.domain, eip712TestCases.valid.types, - eip712TestCases.valid.primaryType, - eip712TestCases.valid.data + eip712TestCases.valid.data, + eip712TestCases.valid.primaryType ); expect(actual).toBe(expected); + const actualWithoutPrimaryType = + await privateKeySigner.signTypedData( + eip712TestCases.valid.domain, + eip712TestCases.valid.types, + eip712TestCases.valid.data + ); + expect(actualWithoutPrimaryType).toBe(expected); }); }); });