diff --git a/guides/integrating-the-safe-core-sdk.md b/guides/integrating-the-safe-core-sdk.md index 59258d609..813d29add 100644 --- a/guides/integrating-the-safe-core-sdk.md +++ b/guides/integrating-the-safe-core-sdk.md @@ -207,14 +207,14 @@ Once we have the Safe transaction object we can share it with the other owners o - `safeAddress`: The Safe address. - `safeTransactionData`: The `data` object inside the Safe transaction object returned from the method `createTransaction`. -- `safeTxHash`: The Safe transaction hash, calculated by calling the method `getTransactionHash` from the Protocol Kit. +- `safeTxHash`: The Safe transaction hash, calculated by calling the method `getHash` from the Protocol Kit. - `senderAddress`: The Safe owner or delegate proposing the transaction. - `senderSignature`: The signature generated by signing the `safeTxHash` with the `senderAddress`. - `origin`: Optional string that allows to provide more information about the app proposing the transaction. ```js -const safeTxHash = await safeSdk.getTransactionHash(safeTransaction) -const senderSignature = await safeSdk.signTransactionHash(safeTxHash) +const safeTxHash = await safeSdk.getHash(safeTransaction) +const senderSignature = await safeSdk.signHash(safeTxHash) await safeService.proposeTransaction({ safeAddress, safeTransactionData: safeTransaction.data, @@ -288,13 +288,13 @@ type SafeMultisigTransactionResponse = { ## 7. Confirm/reject the transaction -The owners of the Safe can now sign the transaction obtained from the Safe Transaction Service by calling the method `signTransactionHash` from the Protocol Kit to generate the signature and by calling the method `confirmTransaction` from the Safe API Kit to add the signature to the service. +The owners of the Safe can now sign the transaction obtained from the Safe Transaction Service by calling the method `signHash` from the Protocol Kit to generate the signature and by calling the method `confirmTransaction` from the Safe API Kit to add the signature to the service. ```js // transaction: SafeMultisigTransactionResponse const hash = transaction.safeTxHash -let signature = await safeSdk.signTransactionHash(hash) +let signature = await safeSdk.signHash(hash) await safeService.confirmTransaction(hash, signature.data) ``` diff --git a/packages/api-kit/tests/endpoint/index.test.ts b/packages/api-kit/tests/endpoint/index.test.ts index 27b8c0e96..1b276107c 100644 --- a/packages/api-kit/tests/endpoint/index.test.ts +++ b/packages/api-kit/tests/endpoint/index.test.ts @@ -364,7 +364,7 @@ describe('Endpoint tests', () => { const signerAddress = await signer.getAddress() const safeSdk = await Safe.create({ ethAdapter, safeAddress }) const safeTransaction = await safeSdk.createTransaction({ safeTransactionData }) - const senderSignature = await safeSdk.signTransactionHash(safeTxHash) + const senderSignature = await safeSdk.signHash(safeTxHash) await chai .expect( safeApiKit.proposeTransaction({ @@ -407,7 +407,7 @@ describe('Endpoint tests', () => { const signerAddress = await signer.getAddress() const safeSdk = await Safe.create({ ethAdapter, safeAddress }) const safeTransaction = await safeSdk.createTransaction({ safeTransactionData }) - const senderSignature = await safeSdk.signTransactionHash(safeTxHash) + const senderSignature = await safeSdk.signHash(safeTxHash) await chai .expect( safeApiKit.proposeTransaction({ diff --git a/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.test.ts b/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.test.ts index c163e744b..f5bf27e4a 100644 --- a/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.test.ts +++ b/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.test.ts @@ -148,8 +148,8 @@ describe('SafeMoneriumClient', () => { data: txData }) - safeSdk.getTransactionHash = jest.fn().mockResolvedValueOnce('0xTransactionHash') - safeSdk.signTransactionHash = jest.fn().mockResolvedValueOnce('0xTransactionSignature') + safeSdk.getHash = jest.fn().mockResolvedValueOnce('0xTransactionHash') + safeSdk.signHash = jest.fn().mockResolvedValueOnce('0xTransactionSignature') jest.spyOn(SafeApiKit.prototype, 'getTransaction').mockResolvedValueOnce({ confirmationsRequired: 1, diff --git a/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.ts b/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.ts index 0d0eaf332..97d98823a 100644 --- a/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.ts +++ b/packages/onramp-kit/src/packs/monerium/SafeMoneriumClient.ts @@ -125,9 +125,9 @@ export class SafeMoneriumClient extends MoneriumClient { } }) - const safeTxHash = await this.#safeSdk.getTransactionHash(safeTransaction) + const safeTxHash = await this.#safeSdk.getHash(safeTransaction) - const senderSignature = await this.#safeSdk.signTransactionHash(safeTxHash) + const senderSignature = await this.#safeSdk.signHash(safeTxHash) const chainId = await this.#safeSdk.getChainId() diff --git a/packages/protocol-kit/README.md b/packages/protocol-kit/README.md index f4553426f..425c60e31 100644 --- a/packages/protocol-kit/README.md +++ b/packages/protocol-kit/README.md @@ -133,7 +133,7 @@ To connect `owner2` to the Safe we need to create a new instance of the class `E ```js const ethAdapterOwner2 = new EthersAdapter({ ethers, signerOrProvider: owner2 }) const safeSdk2 = await safeSdk.connect({ ethAdapter: ethAdapterOwner2, safeAddress }) -const txHash = await safeSdk2.getTransactionHash(safeTransaction) +const txHash = await safeSdk2.getHash(safeTransaction) const approveTxResponse = await safeSdk2.approveTransactionHash(txHash) await approveTxResponse.transactionResponse?.wait() ``` @@ -633,7 +633,7 @@ const safeTransaction1 = await safeSdk.createTransaction({ safeTransactionData } const safeTransaction2 = await copyTransaction(safeTransaction1) ``` -### getTransactionHash +### getHash Returns the transaction hash of a Safe transaction. @@ -642,10 +642,10 @@ const safeTransactionData: SafeTransactionDataPartial = { // ... } const safeTransaction = await safeSdk.createTransaction({ safeTransactionData }) -const txHash = await safeSdk.getTransactionHash(safeTransaction) +const txHash = await safeSdk.getHash(safeTransaction) ``` -### signTransactionHash +### signHash Signs a hash using the current owner account. @@ -654,8 +654,8 @@ const safeTransactionData: SafeTransactionDataPartial = { // ... } const safeTransaction = await safeSdk.createTransaction({ safeTransactionData }) -const txHash = await safeSdk.getTransactionHash(safeTransaction) -const signature = await safeSdk.signTransactionHash(txHash) +const txHash = await safeSdk.getHash(safeTransaction) +const signature = await safeSdk.signHash(txHash) ``` ### signTypedData @@ -701,7 +701,7 @@ const safeTransactionData: SafeTransactionDataPartial = { // ... } const safeTransaction = await safeSdk.createTransaction({ safeTransactionData }) -const txHash = await safeSdk.getTransactionHash(safeTransaction) +const txHash = await safeSdk.getHash(safeTransaction) const txResponse = await safeSdk.approveTransactionHash(txHash) await txResponse.transactionResponse?.wait() ``` @@ -743,7 +743,7 @@ const safeTransactionData: SafeTransactionDataPartial = { // ... } const safeTransaction = await safeSdk.createTransaction({ safeTransactionData }) -const txHash = await safeSdk.getTransactionHash(safeTransaction) +const txHash = await safeSdk.getHash(safeTransaction) const ownerAddresses = await safeSdk.getOwnersWhoApprovedTx(txHash) ``` diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index a78608630..1efe146f0 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -3,16 +3,20 @@ import { EthAdapter, OperationType, SafeMultisigTransactionResponse, + SafeMultisigConfirmationResponse, SafeSignature, SafeTransaction, SafeTransactionDataPartial, - SafeTransactionEIP712Args, + SafeEIP712Args, SafeVersion, TransactionOptions, TransactionResult, MetaTransactionData, - Transaction + Transaction, + CompatibilityFallbackHandlerContract, + EIP712TypedData } from '@safe-global/safe-core-sdk-types' +import { soliditySha3, utf8ToHex } from 'web3-utils' import { PREDETERMINED_SALT_NONCE, encodeSetupCallData, @@ -43,6 +47,7 @@ import { sameString } from './utils' import { + buildSignature, generateEIP712Signature, generatePreValidatedSignature, generateSignature @@ -56,6 +61,7 @@ import { } from './utils/transactions/utils' import { isSafeConfigWithPredictedSafe } from './utils/types' import { + getCompatibilityFallbackHandlerContract, getMultiSendCallOnlyContract, getProxyFactoryContract, getSafeContract @@ -70,6 +76,8 @@ class Safe { #guardManager!: GuardManager #fallbackHandlerManager!: FallbackHandlerManager + #MAGIC_VALUE = '0x1626ba7e' + /** * Creates an instance of the Safe Core SDK. * @param config - Ethers Safe configuration @@ -475,7 +483,7 @@ class Safe { const signedSafeTransaction = await this.createTransaction({ safeTransactionData: safeTransaction.data }) - safeTransaction.signatures.forEach((signature) => { + safeTransaction.signatures.forEach((signature: EthSafeSignature) => { signedSafeTransaction.addSignature(signature) }) return signedSafeTransaction @@ -484,15 +492,21 @@ class Safe { /** * Returns the transaction hash of a Safe transaction. * - * @param safeTransaction - The Safe transaction - * @returns The transaction hash of the Safe transaction + * @param txOrMessage - The Safe transaction or a raw message + * @returns The hashed Safe transaction or message */ - async getTransactionHash(safeTransaction: SafeTransaction): Promise { + async getHash(txOrMessage: SafeTransaction | string): Promise { if (!this.#contractManager.safeContract) { throw new Error('Safe is not deployed') } - const safeTransactionData = safeTransaction.data + + if (typeof txOrMessage === 'string') { + return soliditySha3(utf8ToHex(txOrMessage)) || '' + } + + const safeTransactionData = txOrMessage.data const txHash = await this.#contractManager.safeContract.getTransactionHash(safeTransactionData) + return txHash } @@ -500,30 +514,59 @@ class Safe { * Signs a hash using the current signer account. * * @param hash - The hash to sign + * @param isSmartContract - If the signature is a Smart Contract signature following EIP-1271. Optional. Default value is false * @returns The Safe signature */ - async signTransactionHash(hash: string): Promise { - return generateSignature(this.#ethAdapter, hash) + async signHash(hash: string, isSmartContract = false): Promise { + const signature = await generateSignature(this.#ethAdapter, hash) + + // If is a Smart Contract signature the signer is the Safe and not the signer account + if (isSmartContract) { + const safeAddress = await this.getAddress() + + return new EthSafeSignature(safeAddress, signature.data, isSmartContract) + } + + return signature } /** * Signs a transaction according to the EIP-712 using the current signer account. * - * @param safeTransaction - The Safe transaction to be signed + * @param eip712Data - The Safe Transaction or message hash to be signed * @param methodVersion - EIP-712 version. Optional * @returns The Safe signature */ async signTypedData( - safeTransaction: SafeTransaction, - methodVersion?: 'v3' | 'v4' + eip712Data: SafeTransaction | EIP712TypedData | string, + methodVersion?: 'v3' | 'v4', + isSmartContract = false ): Promise { - const safeTransactionEIP712Args: SafeTransactionEIP712Args = { + let data + + if (eip712Data.hasOwnProperty('signatures')) { + data = (eip712Data as SafeTransaction).data + } else { + data = eip712Data as EIP712TypedData | string + } + + const safeEIP712Args: SafeEIP712Args = { safeAddress: await this.getAddress(), safeVersion: await this.getContractVersion(), chainId: await this.getEthAdapter().getChainId(), - safeTransactionData: safeTransaction.data + data } - return generateEIP712Signature(this.#ethAdapter, safeTransactionEIP712Args, methodVersion) + + const signature = await generateEIP712Signature(this.#ethAdapter, safeEIP712Args, methodVersion) + + // If is a Smart Contract signature the signer is the Safe and not the signer account + if (isSmartContract) { + const safeAddress = await this.getAddress() + + return new EthSafeSignature(safeAddress, signature.data, isSmartContract) + } + + return signature } /** @@ -540,7 +583,8 @@ class Safe { | 'eth_sign' | 'eth_signTypedData' | 'eth_signTypedData_v3' - | 'eth_signTypedData_v4' = 'eth_signTypedData_v4' + | 'eth_signTypedData_v4' = 'eth_signTypedData_v4', + isSmartContract = false ): Promise { const transaction = isSafeMultisigTransactionResponse(safeTransaction) ? await this.toSafeTransactionType(safeTransaction) @@ -560,24 +604,24 @@ class Safe { let signature: SafeSignature if (signingMethod === 'eth_signTypedData_v4') { - signature = await this.signTypedData(transaction, 'v4') + signature = await this.signTypedData(transaction, 'v4', isSmartContract) } else if (signingMethod === 'eth_signTypedData_v3') { - signature = await this.signTypedData(transaction, 'v3') + signature = await this.signTypedData(transaction, 'v3', isSmartContract) } else if (signingMethod === 'eth_signTypedData') { - signature = await this.signTypedData(transaction) + signature = await this.signTypedData(transaction, undefined, isSmartContract) } else { const safeVersion = await this.getContractVersion() if (!hasSafeFeature(SAFE_FEATURES.ETH_SIGN, safeVersion)) { throw new Error('eth_sign is only supported by Safes >= v1.1.0') } - const txHash = await this.getTransactionHash(transaction) - signature = await this.signTransactionHash(txHash) + const txHash = await this.getHash(transaction) + signature = await this.signHash(txHash, isSmartContract) } const signedSafeTransaction = await this.createTransaction({ safeTransactionData: transaction.data }) - transaction.signatures.forEach((signature) => { + transaction.signatures.forEach((signature: EthSafeSignature) => { signedSafeTransaction.addSignature(signature) }) signedSafeTransaction.addSignature(signature) @@ -899,10 +943,12 @@ class Safe { nonce: serviceTransactionResponse.nonce } const safeTransaction = await this.createTransaction({ safeTransactionData }) - serviceTransactionResponse.confirmations?.map((confirmation) => { - const signature = new EthSafeSignature(confirmation.owner, confirmation.signature) - safeTransaction.addSignature(signature) - }) + serviceTransactionResponse.confirmations?.map( + (confirmation: SafeMultisigConfirmationResponse) => { + const signature = new EthSafeSignature(confirmation.owner, confirmation.signature) + safeTransaction.addSignature(signature) + } + ) return safeTransaction } @@ -926,7 +972,7 @@ class Safe { const signedSafeTransaction = await this.copyTransaction(transaction) - const txHash = await this.getTransactionHash(signedSafeTransaction) + const txHash = await this.getHash(signedSafeTransaction) const ownersWhoApprovedTx = await this.getOwnersWhoApprovedTx(txHash) for (const owner of ownersWhoApprovedTx) { signedSafeTransaction.addSignature(generatePreValidatedSignature(owner)) @@ -973,7 +1019,7 @@ class Safe { const signedSafeTransaction = await this.copyTransaction(transaction) - const txHash = await this.getTransactionHash(signedSafeTransaction) + const txHash = await this.getHash(signedSafeTransaction) const ownersWhoApprovedTx = await this.getOwnersWhoApprovedTx(txHash) for (const owner of ownersWhoApprovedTx) { signedSafeTransaction.addSignature(generatePreValidatedSignature(owner)) @@ -1216,6 +1262,81 @@ class Safe { return transactionBatch } + + private async getFallbackHandlerContract(): Promise { + if (!this.#contractManager.safeContract) { + throw new Error('Safe is not deployed') + } + + const safeVersion = + (await this.#contractManager.safeContract.getVersion()) ?? DEFAULT_SAFE_VERSION + const chainId = await this.#ethAdapter.getChainId() + + const compatibilityFallbackHandlerContract = await getCompatibilityFallbackHandlerContract({ + ethAdapter: this.#ethAdapter, + safeVersion, + customContracts: this.#contractManager.contractNetworks?.[chainId] + }) + + return compatibilityFallbackHandlerContract + } + + /** + * Call the CompatibilityFallbackHandler getMessageHash method + * @param messageHash The hash of the message + * @returns Returns the Safe message hash to be signed + * @link https://github.com/safe-global/safe-contracts/blob/8ffae95faa815acf86ec8b50021ebe9f96abde10/contracts/handler/CompatibilityFallbackHandler.sol#L26-L28 + */ + getSafeMessageHash = async (messageHash: string): Promise => { + const safeAddress = await this.getAddress() + const fallbackHandler = await this.getFallbackHandlerContract() + + const data = fallbackHandler.encode('getMessageHash', [messageHash]) + + const safeMessageHash = await this.#ethAdapter.call({ + from: safeAddress, + to: safeAddress, + data: data || '0x' + }) + + return safeMessageHash + } + + /** + * Call the CompatibilityFallbackHandler isValidSignature method + * @param messageHash The hash of the message + * @param signature The signature to be validated or '0x'. You can send as signature one of the following: + * 1) An array of SafeSignature. In this case the signatures are concatenated for validation (buildSignature()) + * 2) The concatenated signatures as string + * 3) '0x' if you want to validate an onchain message (Approved hash) + * @returns A boolean indicating if the signature is valid + * @link https://github.com/safe-global/safe-contracts/blob/main/contracts/handler/CompatibilityFallbackHandler.sol + */ + isValidSignature = async ( + messageHash: string, + signature: SafeSignature[] | string = '0x' + ): Promise => { + const safeAddress = await this.getAddress() + const fallbackHandler = await this.getFallbackHandlerContract() + + const data = fallbackHandler.encode('isValidSignature(bytes32,bytes)', [ + messageHash, + signature && Array.isArray(signature) ? buildSignature(signature) : signature + ]) + + try { + const isValidSignatureResponse = await this.#ethAdapter.call({ + from: safeAddress, + to: safeAddress, + data: data || '0x' + }) + + return isValidSignatureResponse.slice(0, 10).toLowerCase() === this.#MAGIC_VALUE + } catch (error) { + console.error(error) + return false + } + } } export default Safe diff --git a/packages/protocol-kit/src/adapters/ethers/EthersAdapter.ts b/packages/protocol-kit/src/adapters/ethers/EthersAdapter.ts index a9eb7d8de..efda0f658 100644 --- a/packages/protocol-kit/src/adapters/ethers/EthersAdapter.ts +++ b/packages/protocol-kit/src/adapters/ethers/EthersAdapter.ts @@ -4,11 +4,13 @@ import { BigNumber } from '@ethersproject/bignumber' import { Provider } from '@ethersproject/providers' import { generateTypedData, validateEip3770Address } from '@safe-global/protocol-kit/utils' import { + EIP712TypedDataMessage, + EIP712TypedDataTx, Eip3770Address, EthAdapter, EthAdapterTransaction, GetContractProps, - SafeTransactionEIP712Args + SafeEIP712Args } from '@safe-global/safe-core-sdk-types' import { ethers } from 'ethers' import CompatibilityFallbackHandlerContractEthers from './contracts/CompatibilityFallbackHandler/CompatibilityFallbackHandlerEthersContract' @@ -243,19 +245,22 @@ class EthersAdapter implements EthAdapter { return this.#signer.signMessage(messageArray) } - async signTypedData(safeTransactionEIP712Args: SafeTransactionEIP712Args): Promise { + async signTypedData(safeEIP712Args: SafeEIP712Args): Promise { if (!this.#signer) { throw new Error('EthAdapter must be initialized with a signer to use this method') } if (isTypedDataSigner(this.#signer)) { - const typedData = generateTypedData(safeTransactionEIP712Args) + const typedData = generateTypedData(safeEIP712Args) const signature = await this.#signer._signTypedData( typedData.domain, - { SafeTx: typedData.types.SafeTx }, + typedData.primaryType === 'SafeMessage' + ? { SafeMessage: (typedData as EIP712TypedDataMessage).types.SafeMessage } + : { SafeTx: (typedData as EIP712TypedDataTx).types.SafeTx }, typedData.message ) return signature } + throw new Error('The current signer does not implement EIP-712 to sign typed data') } diff --git a/packages/protocol-kit/src/adapters/web3/Web3Adapter.ts b/packages/protocol-kit/src/adapters/web3/Web3Adapter.ts index 5db04dcad..b67b5722a 100644 --- a/packages/protocol-kit/src/adapters/web3/Web3Adapter.ts +++ b/packages/protocol-kit/src/adapters/web3/Web3Adapter.ts @@ -5,7 +5,7 @@ import { EthAdapter, EthAdapterTransaction, GetContractProps, - SafeTransactionEIP712Args + SafeEIP712Args } from '@safe-global/safe-core-sdk-types' import Web3 from 'web3' import { Transaction } from 'web3-core' @@ -267,13 +267,13 @@ class Web3Adapter implements EthAdapter { } async signTypedData( - safeTransactionEIP712Args: SafeTransactionEIP712Args, + safeEIP712Args: SafeEIP712Args, methodVersion?: 'v3' | 'v4' ): Promise { if (!this.#signerAddress) { throw new Error('This method requires a signer') } - const typedData = generateTypedData(safeTransactionEIP712Args) + const typedData = generateTypedData(safeEIP712Args) let method = 'eth_signTypedData_v3' if (methodVersion === 'v4') { method = 'eth_signTypedData_v4' diff --git a/packages/protocol-kit/src/utils/eip-712/index.ts b/packages/protocol-kit/src/utils/eip-712/index.ts index 3d86bc791..c6e21e31b 100644 --- a/packages/protocol-kit/src/utils/eip-712/index.ts +++ b/packages/protocol-kit/src/utils/eip-712/index.ts @@ -1,4 +1,15 @@ -import { GenerateTypedData, SafeTransactionEIP712Args } from '@safe-global/safe-core-sdk-types' +import { TypedDataDomain } from 'ethers' +import { _TypedDataEncoder } from 'ethers/lib/utils' +import { soliditySha3, utf8ToHex } from 'web3-utils' +import { + EIP712MessageTypes, + EIP712TxTypes, + EIP712TypedData, + SafeEIP712Args, + SafeTransactionData, + EIP712TypedDataMessage, + EIP712TypedDataTx +} from '@safe-global/safe-core-sdk-types' import semverSatisfies from 'semver/functions/satisfies' const EQ_OR_GT_1_3_0 = '>=1.3.0' @@ -22,10 +33,7 @@ export const EIP712_DOMAIN = [ ] // This function returns the types structure for signing off-chain messages according to EIP-712 -export function getEip712MessageTypes(safeVersion: string): { - EIP712Domain: typeof EIP712_DOMAIN | typeof EIP712_DOMAIN_BEFORE_V130 - SafeTx: Array<{ type: string; name: string }> -} { +export function getEip712TxTypes(safeVersion: string): EIP712TxTypes { const eip712WithChainId = semverSatisfies(safeVersion, EQ_OR_GT_1_3_0) return { EIP712Domain: eip712WithChainId ? EIP712_DOMAIN : EIP712_DOMAIN_BEFORE_V130, @@ -44,30 +52,74 @@ export function getEip712MessageTypes(safeVersion: string): { } } +export function getEip712MessageTypes(safeVersion: string): EIP712MessageTypes { + const eip712WithChainId = semverSatisfies(safeVersion, EQ_OR_GT_1_3_0) + return { + EIP712Domain: eip712WithChainId ? EIP712_DOMAIN : EIP712_DOMAIN_BEFORE_V130, + SafeMessage: [{ type: 'bytes', name: 'message' }] + } +} + +export const hashTypedData = (typedData: EIP712TypedData): string => { + // `ethers` doesn't require `EIP712Domain` and otherwise throws + const { EIP712Domain: _, ...types } = typedData.types + return _TypedDataEncoder.hash(typedData.domain as TypedDataDomain, types, typedData.message) +} + +const hashMessage = (message: string): string => { + return soliditySha3(utf8ToHex(message)) || '' +} + +const hashSafeMessage = (message: string | EIP712TypedData): string => { + return typeof message === 'string' ? hashMessage(message) : hashTypedData(message) +} + export function generateTypedData({ safeAddress, safeVersion, chainId, - safeTransactionData -}: SafeTransactionEIP712Args): GenerateTypedData { + data +}: SafeEIP712Args): EIP712TypedDataTx | EIP712TypedDataMessage { + const isSafeTransactionDataType = data.hasOwnProperty('to') + const eip712WithChainId = semverSatisfies(safeVersion, EQ_OR_GT_1_3_0) - const typedData: GenerateTypedData = { - types: getEip712MessageTypes(safeVersion), - domain: { - verifyingContract: safeAddress - }, - primaryType: 'SafeTx', - message: { - ...safeTransactionData, - value: safeTransactionData.value, - safeTxGas: safeTransactionData.safeTxGas, - baseGas: safeTransactionData.baseGas, - gasPrice: safeTransactionData.gasPrice, - nonce: safeTransactionData.nonce + + let typedData: EIP712TypedDataTx | EIP712TypedDataMessage + + if (isSafeTransactionDataType) { + const txData = data as SafeTransactionData + + typedData = { + types: getEip712TxTypes(safeVersion), + domain: { + verifyingContract: safeAddress + }, + primaryType: 'SafeTx', + message: { + ...txData, + value: txData.value, + safeTxGas: txData.safeTxGas, + baseGas: txData.baseGas, + gasPrice: txData.gasPrice, + nonce: txData.nonce + } + } + } else { + const message = data as string | EIP712TypedData + + typedData = { + types: getEip712MessageTypes(safeVersion), + domain: { + verifyingContract: safeAddress + }, + primaryType: 'SafeMessage', + message: { message: hashSafeMessage(message) } } } + if (eip712WithChainId) { typedData.domain.chainId = chainId } + return typedData } diff --git a/packages/protocol-kit/src/utils/signatures/SafeSignature.ts b/packages/protocol-kit/src/utils/signatures/SafeSignature.ts index f54c6de61..5e48b0a69 100644 --- a/packages/protocol-kit/src/utils/signatures/SafeSignature.ts +++ b/packages/protocol-kit/src/utils/signatures/SafeSignature.ts @@ -3,6 +3,7 @@ import { SafeSignature } from '@safe-global/safe-core-sdk-types' export class EthSafeSignature implements SafeSignature { signer: string data: string + isSmartContractSignature: boolean /** * Creates an instance of a Safe signature. @@ -11,9 +12,10 @@ export class EthSafeSignature implements SafeSignature { * @param signature - The Safe signature * @returns The Safe signature instance */ - constructor(signer: string, signature: string) { + constructor(signer: string, signature: string, isSmartContractSignature = false) { this.signer = signer this.data = signature + this.isSmartContractSignature = isSmartContractSignature } /** @@ -21,7 +23,11 @@ export class EthSafeSignature implements SafeSignature { * * @returns The static part of the Safe signature */ - staticPart(/* dynamicOffset: number */) { + staticPart(dynamicOffset?: string) { + if (this.isSmartContractSignature) { + return `${this.signer.slice(2).padStart(64, '0')}${dynamicOffset || ''}00` + } + return this.data } @@ -31,6 +37,11 @@ export class EthSafeSignature implements SafeSignature { * @returns The dynamic part of the Safe signature */ dynamicPart() { + if (this.isSmartContractSignature) { + const dynamicPartLength = (this.data.slice(2).length / 2).toString(16).padStart(64, '0') + return `${dynamicPartLength}${this.data.slice(2)}` + } + return '' } } diff --git a/packages/protocol-kit/src/utils/signatures/utils.ts b/packages/protocol-kit/src/utils/signatures/utils.ts index cab75eb71..40e000a14 100644 --- a/packages/protocol-kit/src/utils/signatures/utils.ts +++ b/packages/protocol-kit/src/utils/signatures/utils.ts @@ -1,8 +1,4 @@ -import { - EthAdapter, - SafeSignature, - SafeTransactionEIP712Args -} from '@safe-global/safe-core-sdk-types' +import { EthAdapter, SafeSignature, SafeEIP712Args } from '@safe-global/safe-core-sdk-types' import { bufferToHex, ecrecover, pubToAddress } from 'ethereumjs-util' import { sameString } from '../address' import { EthSafeSignature } from './SafeSignature' @@ -103,21 +99,64 @@ export async function generateSignature( if (!signerAddress) { throw new Error('EthAdapter must be initialized with a signer to use this method') } + let signature = await ethAdapter.signMessage(hash) + signature = adjustVInSignature('eth_sign', signature, hash, signerAddress) return new EthSafeSignature(signerAddress, signature) } export async function generateEIP712Signature( ethAdapter: EthAdapter, - safeTransactionEIP712Args: SafeTransactionEIP712Args, + safeEIP712Args: SafeEIP712Args, methodVersion?: 'v3' | 'v4' ): Promise { const signerAddress = await ethAdapter.getSignerAddress() if (!signerAddress) { throw new Error('EthAdapter must be initialized with a signer to use this method') } - let signature = await ethAdapter.signTypedData(safeTransactionEIP712Args, methodVersion) + + let signature = await ethAdapter.signTypedData(safeEIP712Args, methodVersion) + signature = adjustVInSignature('eth_signTypedData', signature) return new EthSafeSignature(signerAddress, signature) } + +export const buildSignature = (signatures: SafeSignature[]): string => { + const SIGNATURE_LENGTH_BYTES = 65 + + signatures.sort((left, right) => + left.signer.toLowerCase().localeCompare(right.signer.toLowerCase()) + ) + + let signatureBytes = '0x' + let dynamicBytes = '' + + for (const sig of signatures) { + if (sig.isSmartContractSignature) { + /* + A contract signature has a static part of 65 bytes and the dynamic part that needs to be appended + at the end of signature bytes. + The signature format is + Signature type == 0 + Constant part: 65 bytes + {32-bytes signature verifier}{32-bytes dynamic data position}{1-byte signature type} + Dynamic part (solidity bytes): 32 bytes + signature data length + {32-bytes signature length}{bytes signature data} + */ + const dynamicPartPosition = ( + signatures.length * SIGNATURE_LENGTH_BYTES + + dynamicBytes.length / 2 + ) + .toString(16) + .padStart(64, '0') + + signatureBytes += sig.staticPart(dynamicPartPosition) + dynamicBytes += sig.dynamicPart() + } else { + signatureBytes += sig.data.slice(2) + } + } + + return signatureBytes + dynamicBytes +} diff --git a/packages/protocol-kit/src/utils/transactions/SafeTransaction.ts b/packages/protocol-kit/src/utils/transactions/SafeTransaction.ts index 2eb45494e..489285bea 100644 --- a/packages/protocol-kit/src/utils/transactions/SafeTransaction.ts +++ b/packages/protocol-kit/src/utils/transactions/SafeTransaction.ts @@ -3,6 +3,7 @@ import { SafeTransaction, SafeTransactionData } from '@safe-global/safe-core-sdk-types' +import { buildSignature } from '../signatures' class EthSafeTransaction implements SafeTransaction { data: SafeTransactionData @@ -17,16 +18,7 @@ class EthSafeTransaction implements SafeTransaction { } encodedSignatures(): string { - const signers = Array.from(this.signatures.keys()).sort() - const baseOffset = signers.length * 65 - let staticParts = '' - let dynamicParts = '' - signers.forEach((signerAddress) => { - const signature = this.signatures.get(signerAddress) - staticParts += signature?.staticPart(/*baseOffset + dynamicParts.length / 2*/).slice(2) - dynamicParts += signature?.dynamicPart() - }) - return '0x' + staticParts + dynamicParts + return buildSignature(Array.from(this.signatures.values())) } } diff --git a/packages/protocol-kit/tests/e2e/eip1271.test.ts b/packages/protocol-kit/tests/e2e/eip1271.test.ts new file mode 100644 index 000000000..1d8c347a4 --- /dev/null +++ b/packages/protocol-kit/tests/e2e/eip1271.test.ts @@ -0,0 +1,333 @@ +import Safe from '@safe-global/protocol-kit/index' +import { safeVersionDeployed } from '@safe-global/protocol-kit/hardhat/deploy/deploy-contracts' +import { + OperationType, + SafeTransaction, + SafeTransactionDataPartial +} from '@safe-global/safe-core-sdk-types' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { deployments, waffle } from 'hardhat' +import { getContractNetworks } from './utils/setupContractNetworks' +import { getSafeWithOwners } from './utils/setupContracts' +import { getEthAdapter } from './utils/setupEthAdapter' +import { getAccounts } from './utils/setupTestNetwork' +import { waitSafeTxReceipt } from './utils/transactions' +import { itif } from './utils/helpers' +import { BigNumber, ethers } from 'ethers' +import { buildSignature } from '@safe-global/protocol-kit/utils' + +chai.use(chaiAsPromised) + +export const preimageSafeTransactionHash = ( + safeAddress: string, + safeTx: SafeTransaction, + chainId: number +): string => { + return ethers.utils._TypedDataEncoder.encode( + { verifyingContract: safeAddress, chainId }, + { + // "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + SafeTx: [ + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'value' }, + { type: 'bytes', name: 'data' }, + { type: 'uint8', name: 'operation' }, + { type: 'uint256', name: 'safeTxGas' }, + { type: 'uint256', name: 'baseGas' }, + { type: 'uint256', name: 'gasPrice' }, + { type: 'address', name: 'gasToken' }, + { type: 'address', name: 'refundReceiver' }, + { type: 'uint256', name: 'nonce' } + ] + }, + safeTx.data + ) +} + +export const calculateSafeMessageHash = ( + safeAddress: string, + message: string, + chainId: number +): string => { + return ethers.utils._TypedDataEncoder.hash( + { verifyingContract: safeAddress, chainId }, + { + SafeMessage: [{ type: 'bytes', name: 'message' }] + }, + { message } + ) +} + +const MESSAGE = 'I am the owner of this Safe account' + +describe.only('EIP1271', () => { + describe('Using a 2/3 Safe in the context of the EIP1271', async () => { + const setupTests = deployments.createFixture(async ({ deployments }) => { + await deployments.fixture() + const accounts = await getAccounts() + const chainId: number = (await waffle.provider.getNetwork()).chainId + const contractNetworks = await getContractNetworks(chainId) + + const [account1, account2] = accounts + + // Create a 1/1 Safe to sign the messages + const signerSafe = await getSafeWithOwners([accounts[0].address], 1) + + // Create a 2/3 Safe + const safe = await getSafeWithOwners( + [accounts[0].address, accounts[1].address, signerSafe.address], + 2 + ) + + // Adapter and Safe instance for owner 1 + const ethAdapter1 = await getEthAdapter(account1.signer) + const safeSdk1 = await Safe.create({ + ethAdapter: ethAdapter1, + safeAddress: safe.address, + contractNetworks + }) + + // Adapter and Safe instance for owner 2 + const ethAdapter2 = await getEthAdapter(account2.signer) + const safeSdk2 = await Safe.create({ + ethAdapter: ethAdapter2, + safeAddress: safe.address, + contractNetworks + }) + + // Adapter and Safe instance for owner 3 + const ethAdapter3 = await getEthAdapter(signerSafe.signer) + const safeSdk3 = await Safe.create({ + ethAdapter: ethAdapter3, + safeAddress: signerSafe.address, + contractNetworks + }) + + return { + safe, + signerSafe, + accounts, + contractNetworks, + chainId, + ethAdapter1, + ethAdapter2, + ethAdapter3, + safeSdk1, + safeSdk2, + safeSdk3 + } + }) + + itif(safeVersionDeployed >= '1.3.0')( + 'should validate on-chain messages (Approved hashes)', + async () => { + const { contractNetworks, safeSdk1, safeSdk2, ethAdapter1 } = await setupTests() + + const chainId: number = await safeSdk1.getChainId() + const safeVersion = await safeSdk1.getContractVersion() + + const customContract = contractNetworks[chainId] + + const signMessageLibContract = await ethAdapter1.getSignMessageLibContract({ + safeVersion, + customContractAddress: customContract.signMessageLibAddress, + customContractAbi: customContract.signMessageLibAbi + }) + + const messageHash = await safeSdk1.getHash(MESSAGE) + + const txData = signMessageLibContract.encode('signMessage', [messageHash]) + + const safeTransactionData: SafeTransactionDataPartial = { + to: customContract.signMessageLibAddress, + value: '0', + data: txData, + operation: OperationType.DelegateCall + } + + const tx = await safeSdk1.createTransaction({ safeTransactionData }) + const signedTx = await safeSdk1.signTransaction(tx) + const signedTx2 = await safeSdk2.signTransaction(signedTx) + const execResponse = await safeSdk1.executeTransaction(signedTx2) + + await waitSafeTxReceipt(execResponse) + + const validatedResponse1 = await safeSdk1.isValidSignature(messageHash) + chai.expect(validatedResponse1).to.be.true + + const validatedResponse2 = await safeSdk1.isValidSignature(messageHash, '0x') + chai.expect(validatedResponse2).to.be.true + } + ) + + itif(safeVersionDeployed >= '1.3.0')('should validate off-chain messages', async () => { + const { safeSdk1, safeSdk2 } = await setupTests() + + // Hash the message + const messageHash = await safeSdk1.getHash(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + // Sign the Safe message hash with the owners + const ethSignSig1 = await safeSdk1.signHash(safeMessageHash) + const ethSignSig2 = await safeSdk2.signHash(safeMessageHash) + + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid1 = await safeSdk1.isValidSignature( + messageHash, + buildSignature([ethSignSig1, ethSignSig2]) + ) + + chai.expect(isValid1).to.be.true + + // Validate the signature sending the Safe message hash and the array of SafeSignature + const isValid2 = await safeSdk1.isValidSignature(messageHash, [ethSignSig1, ethSignSig2]) + + chai.expect(isValid2).to.be.true + + // Validate the signature is not valid when not enough signers has signed + const isValid3 = await safeSdk1.isValidSignature(messageHash, [ethSignSig1]) + + chai.expect(isValid3).to.be.false + }) + + itif(safeVersionDeployed >= '1.3.0')( + 'should validate a mix EIP191 and EIP712 signatures', + async () => { + const { safeSdk1, safeSdk2 } = await setupTests() + + // Hash the message + const messageHash = await safeSdk1.getHash(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + // Sign the Safe message with the owners + const ethSignSig = await safeSdk1.signHash(safeMessageHash) + + const typedDataSig = await safeSdk2.signTypedData(MESSAGE) + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid = await safeSdk1.isValidSignature(messageHash, [typedDataSig, ethSignSig]) + + chai.expect(isValid).to.be.true + } + ) + + itif(safeVersionDeployed >= '1.3.0')( + 'should validate Smart contracts as signers (threshold = 1)', + async () => { + const { safeSdk1, safeSdk2, safeSdk3 } = await setupTests() + + // Hash the message + const messageHash = await safeSdk1.getHash(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + // Sign the Safe message with the owners + const ethSignSig = await safeSdk1.signHash(safeMessageHash) + const typedDataSig = await safeSdk2.signTypedData(messageHash) + + // Sign with the Smart contract + const safeSignerMessageHash = await safeSdk3.getSafeMessageHash(messageHash) + const signerSafeSig = await safeSdk3.signHash(safeSignerMessageHash, true) + + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid = await safeSdk1.isValidSignature(messageHash, [ + signerSafeSig, + ethSignSig, + typedDataSig + ]) + + chai.expect(isValid).to.be.true + } + ) + + itif(safeVersionDeployed >= '1.3.0')('should revert when message is not signed', async () => { + const { safeSdk1 } = await setupTests() + + const response = await safeSdk1.isValidSignature(await safeSdk1.getHash(MESSAGE), '0x') + + chai.expect(response).to.be.false + }) + + itif(safeVersionDeployed >= '1.3.0')( + 'should generate the correct safeMessageHash', + async () => { + const { safe, safeSdk1 } = await setupTests() + + const chainId = await safeSdk1.getChainId() + const messageHash = await safeSdk1.getHash(MESSAGE) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + chai + .expect(safeMessageHash) + .to.be.eq(calculateSafeMessageHash(safe.address, messageHash, chainId)) + } + ) + + it.only('should allow use to sign transactions using Safe Accounts (threshold = 1)', async () => { + const { safe, accounts, safeSdk1, safeSdk2, safeSdk3, signerSafe } = await setupTests() + + const [account1] = accounts + + await account1.signer.sendTransaction({ + to: safe.address, + value: BigNumber.from('1000000000000000000') // 1 ETH + }) + + const balanceBefore = await safeSdk1.getBalance() + console.log('BALANCE BEFORE: ', balanceBefore.toString()) + + const safeTransactionData: SafeTransactionDataPartial = { + to: account1.address, + value: '100000000000000000', // 0.01 ETH + data: '0x' + } + + const tx = await safeSdk1.createTransaction({ safeTransactionData }) + const txHash = await safeSdk1.getHash(tx) + + const signature1 = await safeSdk1.signHash(await safeSdk1.getSafeMessageHash(txHash)) + const signature2 = await safeSdk3.signHash( + await safeSdk3.getSafeMessageHash( + preimageSafeTransactionHash(signerSafe.address, tx, await safeSdk3.getChainId()) + ), + true + ) + + console.log('OWNER 1: ', signature1.signer) + console.log('OWNER 2: ', signature2.signer) + + // const isValidSignature = await safeSdk1.isValidSignature(txHash, [signature1, signature2]) + // console.log('IS VALID SIGNATURE: ', isValidSignature) + // chai.expect(isValidSignature).to.be.true + + // TODO: This is failing because the owner is invalid + tx.addSignature(signature1) + tx.addSignature(signature2) + console.log(signature1, signature2) + console.log('signature: ', buildSignature([signature1, signature2])) + + const execResponse = await safeSdk1.executeTransaction(tx, { gasLimit: 1000000 }) + + const receipt = await waitSafeTxReceipt(execResponse) + const balanceAfter = await safeSdk1.getBalance() + + console.log('BALANCE AFTER: ', balanceAfter.toString()) + console.log('RECEIPT:', receipt) + chai.expect(tx.signatures.size).to.be.eq(2) + chai.expect(receipt?.status).to.be.eq(1) + + // TODO: This is failing because the owner is invalid + // const signedTx = await safeSdk1.signTransaction(tx) + // const signedTx2 = await safeSdk3.signTransaction(signedTx, 'eth_signTypedData_v4', true) + + // const execResponse = await safeSdk1.executeTransaction(signedTx2, { gasLimit: 1000000 }) + + // const receipt = await waitSafeTxReceipt(execResponse) + // const balanceAfter = await safeSdk1.getBalance() + + // console.log('BALANCE AFTER: ', balanceAfter.toString()) + // console.log('RECEIPT:', receipt) + // chai.expect(tx.signatures.size).to.be.eq(2) + // chai.expect(receipt?.status).to.be.eq(1) + }) + }) +}) diff --git a/packages/protocol-kit/tests/e2e/execution.test.ts b/packages/protocol-kit/tests/e2e/execution.test.ts index e1acfe2f6..461946b9e 100644 --- a/packages/protocol-kit/tests/e2e/execution.test.ts +++ b/packages/protocol-kit/tests/e2e/execution.test.ts @@ -144,7 +144,7 @@ describe('Transactions execution', () => { } const tx = await safeSdk1.createTransaction({ safeTransactionData }) const signedTx = await safeSdk1.signTransaction(tx) - const txHash = await safeSdk2.getTransactionHash(tx) + const txHash = await safeSdk2.getHash(tx) const txResponse = await safeSdk2.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse) await chai @@ -321,7 +321,7 @@ describe('Transactions execution', () => { const tx = await safeSdk1.createTransaction({ safeTransactionData }) // Signature: on-chain - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) const txResponse1 = await safeSdk1.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) @@ -376,7 +376,7 @@ describe('Transactions execution', () => { const tx = await safeSdk1.createTransaction({ safeTransactionData }) // Signature: on-chain - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) const txResponse1 = await safeSdk1.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) @@ -437,7 +437,7 @@ describe('Transactions execution', () => { const tx = await safeSdk1.createTransaction({ safeTransactionData }) // Signature: on-chain - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) const txResponse1 = await safeSdk1.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) @@ -504,7 +504,7 @@ describe('Transactions execution', () => { const tx = await safeSdk1.createTransaction({ safeTransactionData }) // Signature: on-chain - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) const txResponse1 = await safeSdk1.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) @@ -557,7 +557,7 @@ describe('Transactions execution', () => { } const tx = await safeSdk1.createTransaction({ safeTransactionData }) const signedTx = await safeSdk1.signTransaction(tx) - const txHash = await safeSdk2.getTransactionHash(tx) + const txHash = await safeSdk2.getHash(tx) const txResponse1 = await safeSdk2.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) const txResponse2 = await safeSdk3.executeTransaction(signedTx) @@ -835,7 +835,7 @@ describe('Transactions execution', () => { ] const multiSendTx = await safeSdk1.createTransaction({ safeTransactionData }) const signedMultiSendTx = await safeSdk1.signTransaction(multiSendTx) - const txHash = await safeSdk2.getTransactionHash(multiSendTx) + const txHash = await safeSdk2.getHash(multiSendTx) const txResponse1 = await safeSdk2.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) const txResponse2 = await safeSdk3.executeTransaction(signedMultiSendTx) @@ -892,7 +892,7 @@ describe('Transactions execution', () => { ] const multiSendTx = await safeSdk1.createTransaction({ safeTransactionData }) const signedMultiSendTx = await safeSdk1.signTransaction(multiSendTx) - const txHash = await safeSdk2.getTransactionHash(multiSendTx) + const txHash = await safeSdk2.getHash(multiSendTx) const txResponse1 = await safeSdk2.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) const txResponse2 = await safeSdk3.executeTransaction(signedMultiSendTx) diff --git a/packages/protocol-kit/tests/e2e/offChainSignatures.test.ts b/packages/protocol-kit/tests/e2e/offChainSignatures.test.ts index c4d368f94..a34d9f7d3 100644 --- a/packages/protocol-kit/tests/e2e/offChainSignatures.test.ts +++ b/packages/protocol-kit/tests/e2e/offChainSignatures.test.ts @@ -38,7 +38,7 @@ describe('Off-chain signatures', () => { } }) - describe('signTransactionHash', async () => { + describe('signHash', async () => { it('should sign a transaction hash with the current signer if the Safe is not deployed', async () => { const { predictedSafe, accounts, contractNetworks } = await setupTests() const [account1] = accounts @@ -49,7 +49,7 @@ describe('Off-chain signatures', () => { contractNetworks }) const txHash = '0xcbf14050c5fcc9b71d4a3ab874cc728db101d19d4466d56fcdbb805117a28c64' - const signature = await safeSdk.signTransactionHash(txHash) + const signature = await safeSdk.signHash(txHash) chai.expect(signature.staticPart().length).to.be.eq(132) }) @@ -68,8 +68,8 @@ describe('Off-chain signatures', () => { data: '0x' } const tx = await safeSdk.createTransaction({ safeTransactionData }) - const txHash = await safeSdk.getTransactionHash(tx) - const signature = await safeSdk.signTransactionHash(txHash) + const txHash = await safeSdk.getHash(tx) + const signature = await safeSdk.signHash(txHash) chai.expect(signature.staticPart().length).to.be.eq(132) }) }) diff --git a/packages/protocol-kit/tests/e2e/onChainSignatures.test.ts b/packages/protocol-kit/tests/e2e/onChainSignatures.test.ts index 6eef8d655..e713b6f26 100644 --- a/packages/protocol-kit/tests/e2e/onChainSignatures.test.ts +++ b/packages/protocol-kit/tests/e2e/onChainSignatures.test.ts @@ -66,7 +66,7 @@ describe('On-chain signatures', () => { data: '0x' } const tx = await safeSdk1.createTransaction({ safeTransactionData }) - const hash = await safeSdk1.getTransactionHash(tx) + const hash = await safeSdk1.getHash(tx) await chai .expect(safeSdk1.approveTransactionHash(hash)) .to.be.rejectedWith('Transaction hashes can only be approved by Safe owners') @@ -87,7 +87,7 @@ describe('On-chain signatures', () => { data: '0x' } const tx = await safeSdk1.createTransaction({ safeTransactionData }) - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) const txResponse = await safeSdk1.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse) chai.expect(await safe.approvedHashes(account1.address, txHash)).to.be.equal(1) @@ -108,7 +108,7 @@ describe('On-chain signatures', () => { data: '0x' } const tx = await safeSdk1.createTransaction({ safeTransactionData }) - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) chai.expect(await safe.approvedHashes(account1.address, txHash)).to.be.equal(0) const txResponse1 = await safeSdk1.approveTransactionHash(txHash) await waitSafeTxReceipt(txResponse1) @@ -151,7 +151,7 @@ describe('On-chain signatures', () => { data: '0x' } const tx = await safeSdk1.createTransaction({ safeTransactionData }) - const txHash = await safeSdk1.getTransactionHash(tx) + const txHash = await safeSdk1.getHash(tx) const ownersWhoApproved0 = await safeSdk1.getOwnersWhoApprovedTx(txHash) chai.expect(ownersWhoApproved0.length).to.be.eq(0) const txResponse1 = await safeSdk1.approveTransactionHash(txHash) diff --git a/packages/protocol-kit/tests/unit/eip-712.test.ts b/packages/protocol-kit/tests/unit/eip-712.test.ts index ba50ac4ac..31a187c7f 100644 --- a/packages/protocol-kit/tests/unit/eip-712.test.ts +++ b/packages/protocol-kit/tests/unit/eip-712.test.ts @@ -4,7 +4,7 @@ import { EIP712_DOMAIN, EIP712_DOMAIN_BEFORE_V130, generateTypedData, - getEip712MessageTypes + getEip712TxTypes } from '@safe-global/protocol-kit/utils' const safeAddress = '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1' @@ -21,36 +21,36 @@ const safeTransactionData: SafeTransactionData = { nonce: 999 } -describe('EIP-712 sign typed data', () => { - describe('getEip712MessageTypes', async () => { +describe.only('EIP-712 sign typed data', () => { + describe('getEip712TxTypes', async () => { it('should have the domain typed as EIP712_DOMAIN_BEFORE_V130 for Safes == v1.0.0', async () => { - const { EIP712Domain } = getEip712MessageTypes('1.0.0') + const { EIP712Domain } = getEip712TxTypes('1.0.0') chai.expect(EIP712Domain).to.be.eq(EIP712_DOMAIN_BEFORE_V130) }) it('should have the domain typed as EIP712_DOMAIN_BEFORE_V130 for Safes == v1.1.1', async () => { - const { EIP712Domain } = getEip712MessageTypes('1.1.1') + const { EIP712Domain } = getEip712TxTypes('1.1.1') chai.expect(EIP712Domain).to.be.eq(EIP712_DOMAIN_BEFORE_V130) }) it('should have the domain typed as EIP712_DOMAIN_BEFORE_V130 for Safes == v1.2.0', async () => { - const { EIP712Domain } = getEip712MessageTypes('1.2.0') + const { EIP712Domain } = getEip712TxTypes('1.2.0') chai.expect(EIP712Domain).to.be.eq(EIP712_DOMAIN_BEFORE_V130) }) it('should have the domain typed as EIP712_DOMAIN for Safes >= v1.3.0', async () => { - const { EIP712Domain } = getEip712MessageTypes('1.3.0') + const { EIP712Domain } = getEip712TxTypes('1.3.0') chai.expect(EIP712Domain).to.be.eq(EIP712_DOMAIN) }) }) - describe('generateTypedData', async () => { + describe.only('generateTypedData', async () => { it('should generate the typed data for Safes == v1.0.0', async () => { const { domain } = generateTypedData({ safeAddress, safeVersion: '1.0.0', chainId: 4, - safeTransactionData + data: safeTransactionData }) chai.expect(domain.verifyingContract).to.be.eq(safeAddress) chai.expect(domain.chainId).to.be.undefined @@ -61,7 +61,7 @@ describe('EIP-712 sign typed data', () => { safeAddress, safeVersion: '1.1.1', chainId: 4, - safeTransactionData + data: safeTransactionData }) chai.expect(domain.verifyingContract).to.be.eq(safeAddress) chai.expect(domain.chainId).to.be.undefined @@ -72,7 +72,7 @@ describe('EIP-712 sign typed data', () => { safeAddress, safeVersion: '1.2.0', chainId: 4, - safeTransactionData + data: safeTransactionData }) chai.expect(domain.verifyingContract).to.be.eq(safeAddress) chai.expect(domain.chainId).to.be.undefined @@ -84,10 +84,266 @@ describe('EIP-712 sign typed data', () => { safeAddress, safeVersion: '1.3.0', chainId, - safeTransactionData + data: safeTransactionData }) chai.expect(domain.verifyingContract).to.be.eq(safeAddress) chai.expect(domain.chainId).to.be.eq(chainId) }) + + it('should generate the correct types for a EIP-191 message for >= 1.3.0 Safes', () => { + const message = 'Hello world!' + + const safeMessage = generateTypedData({ + safeAddress, + safeVersion: '1.3.0', + chainId: 1, + data: message + }) + + chai.expect(safeMessage).to.deep.eq({ + types: { + EIP712Domain: [ + { + name: 'chainId', + type: 'uint256' + }, + { + name: 'verifyingContract', + type: 'address' + } + ], + SafeMessage: [{ name: 'message', type: 'bytes' }] + }, + domain: { + chainId: 1, + verifyingContract: safeAddress + }, + primaryType: 'SafeMessage', + message: { + message: '0xecd0e108a98e192af1d2c25055f4e3bed784b5c877204e73219a5203251feaab' + } + }) + }) + + it('should generate the correct types for a EIP-191 message for < 1.3.0 Safes', () => { + const message = 'Hello world!' + + const safeMessage = generateTypedData({ + safeAddress, + safeVersion: '1.1.1', + chainId: 1, + data: message + }) + + chai.expect(safeMessage).to.deep.eq({ + types: { + EIP712Domain: [ + { + name: 'verifyingContract', + type: 'address' + } + ], + SafeMessage: [{ name: 'message', type: 'bytes' }] + }, + domain: { + verifyingContract: safeAddress + }, + primaryType: 'SafeMessage', + message: { + message: '0xecd0e108a98e192af1d2c25055f4e3bed784b5c877204e73219a5203251feaab' + } + }) + }) + + it('should generate the correct types for an EIP-712 message for >=1.3.0 Safes', () => { + const message = { + domain: { + chainId: 1, + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1' + }, + message: { + contents: 'Hello, Bob!', + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' + } + }, + primaryType: 'Mail', + types: { + EIP712Domain: [ + { + name: 'name', + type: 'string' + }, + { + name: 'version', + type: 'string' + }, + { + name: 'chainId', + type: 'uint256' + }, + { + name: 'verifyingContract', + type: 'address' + } + ], + Mail: [ + { + name: 'from', + type: 'Person' + }, + { + name: 'to', + type: 'Person' + }, + { + name: 'contents', + type: 'string' + } + ], + Person: [ + { + name: 'name', + type: 'string' + }, + { + name: 'wallet', + type: 'address' + } + ] + } + } + + const safeMessage = generateTypedData({ + safeAddress, + safeVersion: '1.3.0', + chainId: 1, + data: message + }) + + chai.expect(safeMessage).to.deep.eq({ + types: { + EIP712Domain: [ + { + name: 'chainId', + type: 'uint256' + }, + { + name: 'verifyingContract', + type: 'address' + } + ], + SafeMessage: [{ name: 'message', type: 'bytes' }] + }, + domain: { + chainId: 1, + verifyingContract: safeAddress + }, + primaryType: 'SafeMessage', + message: { + message: '0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2' + } + }) + }) + + it('should generate the correct types for an EIP-712 message for <1.3.0 Safes', () => { + const message = { + domain: { + chainId: 1, + name: 'Ether Mail', + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + version: '1' + }, + message: { + contents: 'Hello, Bob!', + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' + } + }, + primaryType: 'Mail', + types: { + EIP712Domain: [ + { + name: 'name', + type: 'string' + }, + { + name: 'version', + type: 'string' + }, + { + name: 'chainId', + type: 'uint256' + }, + { + name: 'verifyingContract', + type: 'address' + } + ], + Mail: [ + { + name: 'from', + type: 'Person' + }, + { + name: 'to', + type: 'Person' + }, + { + name: 'contents', + type: 'string' + } + ], + Person: [ + { + name: 'name', + type: 'string' + }, + { + name: 'wallet', + type: 'address' + } + ] + } + } + + const safeMessage = generateTypedData({ + safeAddress, + safeVersion: '1.1.1', + chainId: 1, + data: message + }) + + chai.expect(safeMessage).to.deep.eq({ + types: { + EIP712Domain: [ + { + name: 'verifyingContract', + type: 'address' + } + ], + SafeMessage: [{ name: 'message', type: 'bytes' }] + }, + domain: { + verifyingContract: safeAddress + }, + primaryType: 'SafeMessage', + message: { + message: '0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2' + } + }) + }) }) }) diff --git a/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts b/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts index 880341eec..fd68d0659 100644 --- a/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts +++ b/packages/safe-core-sdk-types/src/ethereumLibs/EthAdapter.ts @@ -7,11 +7,7 @@ import { SafeContract } from '@safe-global/safe-core-sdk-types/contracts/SafeCon import { SafeProxyFactoryContract } from '@safe-global/safe-core-sdk-types/contracts/SafeProxyFactoryContract' import { SignMessageLibContract } from '@safe-global/safe-core-sdk-types/contracts/SignMessageLibContract' import { SimulateTxAccessorContract } from '@safe-global/safe-core-sdk-types/contracts/SimulateTxAccessorContract' -import { - Eip3770Address, - SafeTransactionEIP712Args, - SafeVersion -} from '@safe-global/safe-core-sdk-types/types' +import { Eip3770Address, SafeEIP712Args, SafeVersion } from '@safe-global/safe-core-sdk-types/types' import { SingletonDeployment } from '@safe-global/safe-deployments' import { AbiItem } from 'web3-utils' @@ -94,10 +90,7 @@ export interface EthAdapter { getTransaction(transactionHash: string): Promise getSignerAddress(): Promise signMessage(message: string): Promise - signTypedData( - safeTransactionEIP712Args: SafeTransactionEIP712Args, - signTypedDataVersion?: string - ): Promise + signTypedData(safeEIP712Args: SafeEIP712Args, signTypedDataVersion?: string): Promise estimateGas( transaction: EthAdapterTransaction, callback?: (error: Error, gas: number) => void diff --git a/packages/safe-core-sdk-types/src/types.ts b/packages/safe-core-sdk-types/src/types.ts index 09382eac4..95df98172 100644 --- a/packages/safe-core-sdk-types/src/types.ts +++ b/packages/safe-core-sdk-types/src/types.ts @@ -47,7 +47,8 @@ export interface SafeTransactionDataPartial extends MetaTransactionData { export interface SafeSignature { readonly signer: string readonly data: string - staticPart(): string + readonly isSmartContractSignature: boolean + staticPart(dynamicOffset?: string): string dynamicPart(): string } @@ -91,14 +92,14 @@ export interface Eip3770Address { address: string } -export interface SafeTransactionEIP712Args { +export interface SafeEIP712Args { safeAddress: string safeVersion: string chainId: number - safeTransactionData: SafeTransactionData + data: SafeTransactionData | EIP712TypedData | string } -export interface Eip712MessageTypes { +export interface EIP712TxTypes { EIP712Domain: { type: string name: string @@ -109,13 +110,28 @@ export interface Eip712MessageTypes { }[] } -export interface GenerateTypedData { - types: Eip712MessageTypes +export interface EIP712MessageTypes { + EIP712Domain: { + type: string + name: string + }[] + SafeMessage: [ + { + type: 'bytes' + name: 'message' + } + ] +} + +export type EIP712Types = EIP712TxTypes | EIP712MessageTypes + +export interface EIP712TypedDataTx { + types: EIP712TxTypes domain: { chainId?: number verifyingContract: string } - primaryType: string + primaryType: 'SafeTx' message: { to: string value: string @@ -130,6 +146,42 @@ export interface GenerateTypedData { } } +export interface EIP712TypedDataMessage { + types: EIP712MessageTypes + domain: { + chainId?: number + verifyingContract: string + } + primaryType: 'SafeMessage' + message: { + message: string + } +} + +interface TypedDataDomain { + name?: string + version?: string + chainId?: unknown + verifyingContract?: string + salt?: ArrayLike | string +} + +interface TypedDataTypes { + name: string + type: string +} + +type TypedMessageTypes = { + [key: string]: TypedDataTypes[] +} + +export interface EIP712TypedData { + domain: TypedDataDomain + types: TypedMessageTypes + message: Record + primaryType?: string +} + export type SafeMultisigConfirmationResponse = { readonly owner: string readonly submissionDate: string diff --git a/playground/api-kit/confirm-transaction.ts b/playground/api-kit/confirm-transaction.ts index e8d841c16..67dcc2f3a 100644 --- a/playground/api-kit/confirm-transaction.ts +++ b/playground/api-kit/confirm-transaction.ts @@ -52,7 +52,7 @@ async function main() { // const transactions = await service.getAllTransactions() const safeTxHash = transaction.transactionHash - const signature = await safe.signTransactionHash(safeTxHash) + const signature = await safe.signHash(safeTxHash) // Confirm the Safe transaction const signatureResponse = await service.confirmTransaction(safeTxHash, signature.data) diff --git a/playground/api-kit/propose-transaction.ts b/playground/api-kit/propose-transaction.ts index a195089c8..b4b7da42a 100644 --- a/playground/api-kit/propose-transaction.ts +++ b/playground/api-kit/propose-transaction.ts @@ -52,8 +52,8 @@ async function main() { const safeTransaction = await safe.createTransaction({ safeTransactionData }) const senderAddress = await signer.getAddress() - const safeTxHash = await safe.getTransactionHash(safeTransaction) - const signature = await safe.signTransactionHash(safeTxHash) + const safeTxHash = await safe.getHash(safeTransaction) + const signature = await safe.signHash(safeTxHash) // Propose transaction to the service await service.proposeTransaction({ diff --git a/playground/config/run.ts b/playground/config/run.ts index 7b546f34a..ad9ceaa0b 100644 --- a/playground/config/run.ts +++ b/playground/config/run.ts @@ -4,7 +4,8 @@ const playInput = process.argv[2] const playgroundProtocolKitPaths = { 'deploy-safe': 'protocol-kit/deploy-safe', - 'generate-safe-address': 'protocol-kit/generate-safe-address' + 'generate-safe-address': 'protocol-kit/generate-safe-address', + eip1271: 'protocol-kit/eip1271' } const playgroundApiKitPaths = { 'propose-transaction': 'api-kit/propose-transaction', diff --git a/playground/protocol-kit/eip1271.ts b/playground/protocol-kit/eip1271.ts new file mode 100644 index 000000000..0f531e48f --- /dev/null +++ b/playground/protocol-kit/eip1271.ts @@ -0,0 +1,65 @@ +import Safe from '@safe-global/protocol-kit' +import { EthersAdapter } from '@safe-global/protocol-kit' +import { ethers } from 'ethers' + +// This file can be used to play around with the Safe Core SDK + +interface Config { + RPC_URL: string + OWNER1_PRIVATE_KEY: string + OWNER2_PRIVATE_KEY: string + OWNER3_PRIVATE_KEY: string + SAFE_2_3_ADDRESS: string +} + +const config: Config = { + RPC_URL: '', + // Create a Safe 2/3 with 3 owners and fill this info + OWNER1_PRIVATE_KEY: '', + OWNER2_PRIVATE_KEY: '', + OWNER3_PRIVATE_KEY: '', + SAFE_2_3_ADDRESS: '' +} + +async function main() { + const provider = new ethers.providers.JsonRpcProvider(config.RPC_URL) + const signer1 = new ethers.Wallet(config.OWNER1_PRIVATE_KEY, provider) + const signer2 = new ethers.Wallet(config.OWNER2_PRIVATE_KEY, provider) + + // Create safeSdk instances + const safeSdk1 = await Safe.create({ + ethAdapter: new EthersAdapter({ + ethers, + signerOrProvider: signer1 + }), + safeAddress: config.SAFE_2_3_ADDRESS + }) + + const safeSdk2 = await Safe.create({ + ethAdapter: new EthersAdapter({ + ethers, + signerOrProvider: signer2 + }), + safeAddress: config.SAFE_2_3_ADDRESS + }) + + const MESSAGE_TO_SIGN = 'I am the owner of this Safe account' + + const messageHash = await safeSdk1.getHash(MESSAGE_TO_SIGN) + const safeMessageHash = await safeSdk1.getSafeMessageHash(messageHash) + + const ethSignSig = await safeSdk1.signHash(safeMessageHash) + const typedDataSig = await safeSdk2.signTypedData(messageHash) + + // Validate the signature sending the Safe message hash and the concatenated signatures + const isValid = await safeSdk1.isValidSignature(messageHash, [typedDataSig, ethSignSig]) + + console.log('Message: ', MESSAGE_TO_SIGN) + console.log('Message Hash: ', messageHash) + console.log('Safe Message Hash: ', safeMessageHash) + console.log('Signatures: ', ethSignSig, typedDataSig) + + console.log(`The signature is ${isValid ? 'valid' : 'invalid'}`) +} + +main()